diff --git a/docs/compiled/artplayer-plugin-chromecast.js b/docs/compiled/artplayer-plugin-chromecast.js
index f6a65e412..ee2470036 100644
--- a/docs/compiled/artplayer-plugin-chromecast.js
+++ b/docs/compiled/artplayer-plugin-chromecast.js
@@ -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?(t.artplayerPluginChromecast=t(),define(function(){return t.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";return function(e){let t=!1,o=null,n=null;const a=e=>{const t=document.querySelector(".art-icon-cast");if(t)switch(e){case"connected":t.style.color="red";break;case"connecting":case"disconnecting":t.style.color="orange";break;default:t.style.color="white"}},c=()=>new Promise((c,s)=>{var i;window.__onGCastApiAvailable=i=>{if(i){const s=window.cast.framework.CastContext.getInstance();s.setOptions({receiverApplicationId:window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED}),s.addEventListener(window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,t=>{const c=window.cast.framework.SessionState;switch(n=t.sessionState,o=t.session,t.sessionState){case c.NO_SESSION:e.onStateChange?.("disconnected"),a("disconnected");break;case c.SESSION_STARTING:e.onStateChange?.("connecting"),a("connecting");break;case c.SESSION_STARTED:e.onStateChange?.("connected"),a("connected");break;case c.SESSION_ENDING:e.onStateChange?.("disconnecting"),a("disconnecting");break;case c.SESSION_RESUMED:e.onStateChange?.("connected"),a("connected")}}),s.addEventListener(window.cast.framework.CastContextEventType.CAST_STATE_CHANGED,t=>{const o=window.cast.framework.CastState;switch(t.castState){case o.NO_DEVICES_AVAILABLE:e.onCastAvailable?.(!1);break;case o.NOT_CONNECTED:case o.CONNECTING:case o.CONNECTED:e.onCastAvailable?.(!0)}}),t=!0,c()}else s(new Error("Cast API is not available"))},window.chrome&&window.chrome.cast||(i=e.sdk||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",new Promise((e,t)=>{const o=document.createElement("script");o.src=i,o.onload=e,o.onerror=t,document.body.appendChild(o)})).catch(s)}),s=(t,o)=>{const n=e.url||t.option.url,a=new window.chrome.cast.media.MediaInfo(n,e.mimeType||function(e){return{mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"}[e.split("?")[0].split("#")[0].split(".").pop().toLowerCase()]||"application/octet-stream"}(n)),c=new window.chrome.cast.media.LoadRequest(a);o.loadMedia(c).then(()=>{t.notice.show="Casting started",e.onCastStart?.()}).catch(o=>{throw t.notice.show="Error casting media",e.onError?.(o),o})};return async a=>(a.controls.add({name:"chromecast",position:"right",tooltip:"Chromecast",html:`${e.icon||''}`,click:async()=>{if(!t)try{await c()}catch(i){throw a.notice.show="Failed to initialize Cast API",e.onError?.(i),i}const n=window.cast.framework.CastContext.getInstance();if(o)s(a,o);else try{const e=await n.requestSession();s(a,e)}catch(i){throw a.notice.show="Error connecting to cast session",e.onError?.(i),i}}}),{name:"artplayerPluginChromecast",getCastState:()=>n,isCasting:()=>null!==o})}});
+!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginChromecast=t(),define(function(){return e.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";const e={mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"};function t(t,o,n,i){const r=n.url||i,s=r.split("?")[0].split("#")[0].split(".").pop().toLowerCase(),a=new t.media.MediaInfo(r,n.mimeType||e[s]||"application/octet-stream");return o.loadMedia(new t.media.LoadRequest(a))}const o=new WeakMap,n=new WeakSet;function i(e){const t=e.cast?.framework,o=e.chrome?.cast;if("function"===typeof t?.CastContext?.getInstance&&t.CastContextEventType&&t.SessionState&&t.CastState&&"function"===typeof o?.media?.MediaInfo&&"function"===typeof o.media.LoadRequest&&o.AutoJoinPolicy)return{framework:t,media:o.media,autoJoinPolicy:o.AutoJoinPolicy.ORIGIN_SCOPED}}function r(e,t){let o;const n=new Promise((n,i)=>{o={ready:t,resolve:n,reject:i},e.subscribers.add(o)});let i=!1;return{promise:n,release:()=>{i||(i=!0,e.subscribers.delete(o),o.reject(new Error("Cast initialization cancelled")),e.subscribers.size||e.cancel())}}}function s(e,s){let a,c,l,d,u,f,m=!1,v=null,S=null,p=0,E=!1;const w=new Promise(e=>{f=()=>e(void 0)});let C;const h=()=>!m&&!e.isDestroy,y=e=>{if(s.onStateChange?.(e),!h())return;const t=C?.querySelector(".art-icon-cast");t&&(t.style.color="connected"===e?"red":"connecting"===e||"disconnecting"===e?"orange":"white")},g=e=>{if(!h()||!a)return;S=e.sessionState;const t=a.framework.SessionState;S===t.NO_SESSION||S===t.SESSION_ENDED||S===t.SESSION_START_FAILED?((S!==t.NO_SESSION||E)&&(p++,u?.()),v=null,y("disconnected")):((S===t.SESSION_ENDING||v&&e.session&&e.session!==v)&&(p++,u?.()),v=e.session||null,S===t.SESSION_STARTING?y("connecting"):S===t.SESSION_ENDING?y("disconnecting"):S!==t.SESSION_STARTED&&S!==t.SESSION_RESUMED||y("connected"))},A=e=>{if(!h()||!a)return;const t=a.framework.CastState;e.castState===t.NO_DEVICES_AVAILABLE?s.onCastAvailable?.(!1):e.castState!==t.NOT_CONNECTED&&e.castState!==t.CONNECTING&&e.castState!==t.CONNECTED||s.onCastAvailable?.(!0)},_=()=>{const e=c,t=a;if(c=void 0,a=void 0,e&&t){const n=t.framework.CastContextEventType,i=[];try{e.removeEventListener(n.SESSION_STATE_CHANGED,g)}catch(o){i.push(o)}try{e.removeEventListener(n.CAST_STATE_CHANGED,A)}catch(o){i.push(o)}if(i.length)throw i[0]}},N=()=>{if(m)return;m=!0,f();const t=d;d=void 0;const o=[];for(const i of[t,_,()=>e.off("destroy",N)])try{i?.()}catch(n){o.push(n)}if(v=null,C=void 0,o.length)throw o[0]};async function I(){const l=p,f=()=>h()&&p===l;let m;const S=new Promise(e=>{m=()=>e(void 0)});u=m;let C="Failed to initialize Cast API";try{if(!a||!c){const e=function(e,t){const n=window,s=i(n);if(s)return{promise:new Promise((e,o)=>{try{t(s),e(s)}catch(n){o(n)}}),release:()=>{}};const a=o.get(n);if(a)return r(a,t);const c={subscribers:new Set,cancel:()=>v(void 0,new Error("Cast initialization cancelled"))};o.set(n,c);const l=r(c,t),d=n.__onGCastApiAvailable;let u,f,m=!1;function v(e,t){if(!m){m=!0,void 0!==f&&n.clearTimeout(f),o.delete(n),n.__onGCastApiAvailable===S&&(n.__onGCastApiAvailable=d),u&&(u.onload=null,u.onerror=null,e||u.remove());for(const o of c.subscribers)try{e?(o.ready(e),o.resolve(e)):o.reject(t)}catch(i){o.reject(i)}c.subscribers.clear()}}function S(e,t){try{d?.call(n,e,t)}finally{v(e?i(n):void 0,new Error("Cast API is not available"))}}n.__onGCastApiAvailable=S;try{f=n.setTimeout(()=>v(void 0,new Error("Timed out initializing Cast API")),3e4),u=document.createElement("script"),u.src=e||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",u.onerror=e=>v(void 0,e),u.onload=()=>{const e=i(n);e&&v(e)},document.body.appendChild(u)}catch(p){v(void 0,p)}return l}(s.sdk,e=>{if(!f())return;if(a=e,c=function(e){const t=e.framework.CastContext.getInstance();return n.has(t)||(t.setOptions({receiverApplicationId:e.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:e.autoJoinPolicy}),n.add(t)),t}(a),!f())return;const t=a.framework.CastContextEventType;try{c.addEventListener(t.SESSION_STATE_CHANGED,g),h()&&c.addEventListener(t.CAST_STATE_CHANGED,A)}catch(o){try{_()}catch{}throw o}});d=e.release,await Promise.race([e.promise,w,S]),e.release(),d=void 0}if(!f()||!c||!a)return;C="Error connecting to cast session",E=!0;let l=c.getCurrentSession();if(!f())return;if(!l){if(await Promise.race([c.requestSession(),w,S]),!f())return;l=c.getCurrentSession()}if(!f())return;if(!l)throw new Error("No active Cast session");v=l,C="Error casting media",await Promise.race([t(a,l,s,e.option.url),w,S]),f()&&(e.notice.show="Casting started",s.onCastStart?.())}catch(y){if(f())throw e.notice.show=C,s.onError?.(y),y}finally{d?.(),d=void 0,u===m&&(u=void 0),E=!1}}return e.controls.add({name:"chromecast",position:"right",tooltip:"Chromecast",html:`${s.icon||'\r\n'}`,mounted(e){h()&&(C=e)},click:()=>{if(!h())return Promise.resolve();if(!l){let e,t;l=new Promise((o,n)=>{e=o,t=n}),l.catch(()=>{}),I().then(()=>{l=void 0,e()},e=>{l=void 0,t(e)})}return l}}),e.on("destroy",N),e.isDestroy&&N(),{name:"artplayerPluginChromecast",getCastState:()=>S,isCasting:()=>null!==v}}function a(e){return async t=>s(t,e)}return Object.defineProperty(a,"default",{value:a,writable:!0,configurable:!0}),a});
diff --git a/docs/compiled/artplayer-plugin-chromecast.legacy.js b/docs/compiled/artplayer-plugin-chromecast.legacy.js
index a9bf8d244..b53bb0487 100644
--- a/docs/compiled/artplayer-plugin-chromecast.legacy.js
+++ b/docs/compiled/artplayer-plugin-chromecast.legacy.js
@@ -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?(t.artplayerPluginChromecast=t(),define(function(){return t.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";var e=(e,t,n)=>new Promise((o,a)=>{var c=e=>{try{i(n.next(e))}catch(t){a(t)}},s=e=>{try{i(n.throw(e))}catch(t){a(t)}},i=e=>e.done?o(e.value):Promise.resolve(e.value).then(c,s);i((n=n.apply(e,t)).next())});return function(t){let n=!1,o=null,a=null;const c=e=>{const t=document.querySelector(".art-icon-cast");if(t)switch(e){case"connected":t.style.color="red";break;case"connecting":case"disconnecting":t.style.color="orange";break;default:t.style.color="white"}},s=()=>new Promise((e,s)=>{var i;window.__onGCastApiAvailable=i=>{if(i){const s=window.cast.framework.CastContext.getInstance();s.setOptions({receiverApplicationId:window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED}),s.addEventListener(window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,e=>{var n,s,i,l,r;const d=window.cast.framework.SessionState;switch(a=e.sessionState,o=e.session,e.sessionState){case d.NO_SESSION:null==(n=t.onStateChange)||n.call(t,"disconnected"),c("disconnected");break;case d.SESSION_STARTING:null==(s=t.onStateChange)||s.call(t,"connecting"),c("connecting");break;case d.SESSION_STARTED:null==(i=t.onStateChange)||i.call(t,"connected"),c("connected");break;case d.SESSION_ENDING:null==(l=t.onStateChange)||l.call(t,"disconnecting"),c("disconnecting");break;case d.SESSION_RESUMED:null==(r=t.onStateChange)||r.call(t,"connected"),c("connected")}}),s.addEventListener(window.cast.framework.CastContextEventType.CAST_STATE_CHANGED,e=>{var n,o,a;const c=window.cast.framework.CastState;switch(e.castState){case c.NO_DEVICES_AVAILABLE:null==(n=t.onCastAvailable)||n.call(t,!1);break;case c.NOT_CONNECTED:null==(o=t.onCastAvailable)||o.call(t,!0);break;case c.CONNECTING:case c.CONNECTED:null==(a=t.onCastAvailable)||a.call(t,!0)}}),n=!0,e()}else s(new Error("Cast API is not available"))},window.chrome&&window.chrome.cast||(i=t.sdk||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",new Promise((e,t)=>{const n=document.createElement("script");n.src=i,n.onload=e,n.onerror=t,document.body.appendChild(n)})).catch(s)}),i=(e,n)=>{const o=t.url||e.option.url,a=new window.chrome.cast.media.MediaInfo(o,t.mimeType||function(e){return{mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"}[e.split("?")[0].split("#")[0].split(".").pop().toLowerCase()]||"application/octet-stream"}(o)),c=new window.chrome.cast.media.LoadRequest(a);n.loadMedia(c).then(()=>{var n;e.notice.show="Casting started",null==(n=t.onCastStart)||n.call(t)}).catch(n=>{var o;throw e.notice.show="Error casting media",null==(o=t.onError)||o.call(t,n),n})};return c=>e(null,null,function*(){return c.controls.add({name:"chromecast",position:"right",tooltip:"Chromecast",html:`${t.icon||''}`,click:()=>e(null,null,function*(){var e,a;if(!n)try{yield s()}catch(r){throw c.notice.show="Failed to initialize Cast API",null==(e=t.onError)||e.call(t,r),r}const l=window.cast.framework.CastContext.getInstance();if(o)i(c,o);else try{const e=yield l.requestSession();i(c,e)}catch(r){throw c.notice.show="Error connecting to cast session",null==(a=t.onError)||a.call(t,r),r}})}),{name:"artplayerPluginChromecast",getCastState:()=>a,isCasting:()=>null!==o}})}});
+!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginChromecast=t(),define(function(){return e.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";var e=(e,t,o)=>new Promise((n,i)=>{var r=e=>{try{a(o.next(e))}catch(t){i(t)}},s=e=>{try{a(o.throw(e))}catch(t){i(t)}},a=e=>e.done?n(e.value):Promise.resolve(e.value).then(r,s);a((o=o.apply(e,t)).next())});const t={mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"};function o(e,o,n,i){const r=n.url||i,s=r.split("?")[0].split("#")[0].split(".").pop().toLowerCase(),a=new e.media.MediaInfo(r,n.mimeType||t[s]||"application/octet-stream");return o.loadMedia(new e.media.LoadRequest(a))}const n=new WeakMap,i=new WeakSet;function r(e){var t,o,n,i;const r=null==(t=e.cast)?void 0:t.framework,s=null==(o=e.chrome)?void 0:o.cast;if("function"===typeof(null==(n=null==r?void 0:r.CastContext)?void 0:n.getInstance)&&r.CastContextEventType&&r.SessionState&&r.CastState&&"function"===typeof(null==(i=null==s?void 0:s.media)?void 0:i.MediaInfo)&&"function"===typeof s.media.LoadRequest&&s.AutoJoinPolicy)return{framework:r,media:s.media,autoJoinPolicy:s.AutoJoinPolicy.ORIGIN_SCOPED}}function s(e,t){let o;const n=new Promise((n,i)=>{o={ready:t,resolve:n,reject:i},e.subscribers.add(o)});let i=!1;return{promise:n,release:()=>{i||(i=!0,e.subscribers.delete(o),o.reject(new Error("Cast initialization cancelled")),e.subscribers.size||e.cancel())}}}function a(t,a){let l,c,u,d,v,f,m=!1,S=null,p=null,E=0,C=!1;const w=new Promise(e=>{f=()=>e(void 0)});let h;const y=()=>!m&&!t.isDestroy,g=e=>{var t;if(null==(t=a.onStateChange)||t.call(a,e),!y())return;const o=null==h?void 0:h.querySelector(".art-icon-cast");o&&(o.style.color="connected"===e?"red":"connecting"===e||"disconnecting"===e?"orange":"white")},A=e=>{if(!y()||!l)return;p=e.sessionState;const t=l.framework.SessionState;p===t.NO_SESSION||p===t.SESSION_ENDED||p===t.SESSION_START_FAILED?((p!==t.NO_SESSION||C)&&(E++,null==v||v()),S=null,g("disconnected")):((p===t.SESSION_ENDING||S&&e.session&&e.session!==S)&&(E++,null==v||v()),S=e.session||null,p===t.SESSION_STARTING?g("connecting"):p===t.SESSION_ENDING?g("disconnecting"):p!==t.SESSION_STARTED&&p!==t.SESSION_RESUMED||g("connected"))},_=e=>{var t,o;if(!y()||!l)return;const n=l.framework.CastState;e.castState===n.NO_DEVICES_AVAILABLE?null==(t=a.onCastAvailable)||t.call(a,!1):e.castState!==n.NOT_CONNECTED&&e.castState!==n.CONNECTING&&e.castState!==n.CONNECTED||null==(o=a.onCastAvailable)||o.call(a,!0)},N=()=>{const e=c,t=l;if(c=void 0,l=void 0,e&&t){const n=t.framework.CastContextEventType,i=[];try{e.removeEventListener(n.SESSION_STATE_CHANGED,A)}catch(o){i.push(o)}try{e.removeEventListener(n.CAST_STATE_CHANGED,_)}catch(o){i.push(o)}if(i.length)throw i[0]}},I=()=>{if(m)return;m=!0,f();const e=d;d=void 0;const o=[];for(const i of[e,N,()=>t.off("destroy",I)])try{null==i||i()}catch(n){o.push(n)}if(S=null,h=void 0,o.length)throw o[0]};function T(){return e(this,null,function*(){var e,u;const f=E,m=()=>y()&&E===f;let p;const h=new Promise(e=>{p=()=>e(void 0)});v=p;let g="Failed to initialize Cast API";try{if(!l||!c){const e=function(e,t){const o=window,i=r(o);if(i)return{promise:new Promise((e,o)=>{try{t(i),e(i)}catch(n){o(n)}}),release:()=>{}};const a=n.get(o);if(a)return s(a,t);const l={subscribers:new Set,cancel:()=>m(void 0,new Error("Cast initialization cancelled"))};n.set(o,l);const c=s(l,t),u=o.__onGCastApiAvailable;let d,v,f=!1;function m(e,t){if(!f){f=!0,void 0!==v&&o.clearTimeout(v),n.delete(o),o.__onGCastApiAvailable===S&&(o.__onGCastApiAvailable=u),d&&(d.onload=null,d.onerror=null,e||d.remove());for(const o of l.subscribers)try{e?(o.ready(e),o.resolve(e)):o.reject(t)}catch(i){o.reject(i)}l.subscribers.clear()}}function S(e,t){try{null==u||u.call(o,e,t)}finally{m(e?r(o):void 0,new Error("Cast API is not available"))}}o.__onGCastApiAvailable=S;try{v=o.setTimeout(()=>m(void 0,new Error("Timed out initializing Cast API")),3e4),d=document.createElement("script"),d.src=e||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",d.onerror=e=>m(void 0,e),d.onload=()=>{const e=r(o);e&&m(e)},document.body.appendChild(d)}catch(p){m(void 0,p)}return c}(a.sdk,e=>{if(!m())return;if(l=e,c=function(e){const t=e.framework.CastContext.getInstance();return i.has(t)||(t.setOptions({receiverApplicationId:e.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:e.autoJoinPolicy}),i.add(t)),t}(l),!m())return;const t=l.framework.CastContextEventType;try{c.addEventListener(t.SESSION_STATE_CHANGED,A),y()&&c.addEventListener(t.CAST_STATE_CHANGED,_)}catch(o){try{N()}catch(n){}throw o}});d=e.release,yield Promise.race([e.promise,w,h]),e.release(),d=void 0}if(!m()||!c||!l)return;g="Error connecting to cast session",C=!0;let u=c.getCurrentSession();if(!m())return;if(!u){if(yield Promise.race([c.requestSession(),w,h]),!m())return;u=c.getCurrentSession()}if(!m())return;if(!u)throw new Error("No active Cast session");S=u,g="Error casting media",yield Promise.race([o(l,u,a,t.option.url),w,h]),m()&&(t.notice.show="Casting started",null==(e=a.onCastStart)||e.call(a))}catch(I){if(m())throw t.notice.show=g,null==(u=a.onError)||u.call(a,I),I}finally{null==d||d(),d=void 0,v===p&&(v=void 0),C=!1}})}return t.controls.add({name:"chromecast",position:"right",tooltip:"Chromecast",html:`${a.icon||'\r\n'}`,mounted(e){y()&&(h=e)},click:()=>{if(!y())return Promise.resolve();if(!u){let e,t;u=new Promise((o,n)=>{e=o,t=n}),u.catch(()=>{}),T().then(()=>{u=void 0,e()},e=>{u=void 0,t(e)})}return u}}),t.on("destroy",I),t.isDestroy&&I(),{name:"artplayerPluginChromecast",getCastState:()=>p,isCasting:()=>null!==S}}function l(t){return o=>e(null,null,function*(){return a(o,t)})}return Object.defineProperty(l,"default",{value:l,writable:!0,configurable:!0}),l});
diff --git a/docs/compiled/artplayer-plugin-chromecast.mjs b/docs/compiled/artplayer-plugin-chromecast.mjs
index 394e29f20..6d2367326 100644
--- a/docs/compiled/artplayer-plugin-chromecast.mjs
+++ b/docs/compiled/artplayer-plugin-chromecast.mjs
@@ -4,173 +4,359 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
-function loadScript(src) {
- return new Promise((resolve, reject) => {
- const script = document.createElement("script");
- script.src = src;
- script.onload = resolve;
- script.onerror = reject;
- document.body.appendChild(script);
- });
-}
-function getMimeType(url) {
+const icon = '\r\n';
+const mimeTypes = {
+ mp4: "video/mp4",
+ webm: "video/webm",
+ ogg: "video/ogg",
+ ogv: "video/ogg",
+ mp3: "audio/mp3",
+ wav: "audio/wav",
+ flv: "video/x-flv",
+ mov: "video/quicktime",
+ avi: "video/x-msvideo",
+ wmv: "video/x-ms-wmv",
+ mpd: "application/dash+xml",
+ m3u8: "application/x-mpegURL"
+};
+function loadMedia(sdk, session, option, currentUrl) {
+ const url = option.url || currentUrl;
const extension = url.split("?")[0].split("#")[0].split(".").pop().toLowerCase();
- const mimeTypes = {
- mp4: "video/mp4",
- webm: "video/webm",
- ogg: "video/ogg",
- ogv: "video/ogg",
- mp3: "audio/mp3",
- wav: "audio/wav",
- flv: "video/x-flv",
- mov: "video/quicktime",
- avi: "video/x-msvideo",
- wmv: "video/x-ms-wmv",
- mpd: "application/dash+xml",
- m3u8: "application/x-mpegURL"
- };
- return mimeTypes[extension] || "application/octet-stream";
+ const info = new sdk.media.MediaInfo(url, option.mimeType || mimeTypes[extension] || "application/octet-stream");
+ return session.loadMedia(new sdk.media.LoadRequest(info));
}
-function artplayerPluginChromecast(option) {
- const DEFAULT_ICON = ``;
- const DEFAULT_SDK = "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
- let isCastInitialized = false;
- let castSession = null;
- let castState = null;
- const updateCastButton = (state) => {
- const button = document.querySelector(".art-icon-cast");
- if (button) {
- switch (state) {
- case "connected":
- button.style.color = "red";
- break;
- case "connecting":
- case "disconnecting":
- button.style.color = "orange";
- break;
- case "disconnected":
- default:
- button.style.color = "white";
- break;
+const DEFAULT_SDK = "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
+const pending = /* @__PURE__ */ new WeakMap();
+const configured = /* @__PURE__ */ new WeakSet();
+function ready(host) {
+ const framework = host.cast?.framework;
+ const api = host.chrome?.cast;
+ if (typeof framework?.CastContext?.getInstance === "function" && framework.CastContextEventType && framework.SessionState && framework.CastState && typeof api?.media?.MediaInfo === "function" && typeof api.media.LoadRequest === "function" && api.AutoJoinPolicy) {
+ return { framework, media: api.media, autoJoinPolicy: api.AutoJoinPolicy.ORIGIN_SCOPED };
+ }
+}
+function configure(sdk) {
+ const context = sdk.framework.CastContext.getInstance();
+ if (!configured.has(context)) {
+ context.setOptions({ receiverApplicationId: sdk.media.DEFAULT_MEDIA_RECEIVER_APP_ID, autoJoinPolicy: sdk.autoJoinPolicy });
+ configured.add(context);
+ }
+ return context;
+}
+function lease(loading, onReady) {
+ let subscriber;
+ const promise = new Promise((resolve, reject) => {
+ subscriber = { ready: onReady, resolve, reject };
+ loading.subscribers.add(subscriber);
+ });
+ let released = false;
+ return { promise, release: () => {
+ if (released)
+ return;
+ released = true;
+ loading.subscribers.delete(subscriber);
+ subscriber.reject(new Error("Cast initialization cancelled"));
+ if (!loading.subscribers.size)
+ loading.cancel();
+ } };
+}
+function loadSdk(src, onReady) {
+ const host = window;
+ const loaded = ready(host);
+ if (loaded) {
+ const promise = new Promise((resolve, reject) => {
+ try {
+ onReady(loaded);
+ resolve(loaded);
+ } catch (error) {
+ reject(error);
+ }
+ });
+ return { promise, release: () => {
+ } };
+ }
+ const existing = pending.get(host);
+ if (existing)
+ return lease(existing, onReady);
+ const loading = { subscribers: /* @__PURE__ */ new Set(), cancel: () => finish(void 0, new Error("Cast initialization cancelled")) };
+ pending.set(host, loading);
+ const subscription = lease(loading, onReady);
+ const previous = host.__onGCastApiAvailable;
+ let script;
+ let timer;
+ let settled = false;
+ function finish(sdk, error) {
+ if (settled)
+ return;
+ settled = true;
+ if (timer !== void 0)
+ host.clearTimeout(timer);
+ pending.delete(host);
+ if (host.__onGCastApiAvailable === available)
+ host.__onGCastApiAvailable = previous;
+ if (script) {
+ script.onload = null;
+ script.onerror = null;
+ if (!sdk)
+ script.remove();
+ }
+ for (const subscriber of loading.subscribers) {
+ try {
+ if (sdk) {
+ subscriber.ready(sdk);
+ subscriber.resolve(sdk);
+ } else {
+ subscriber.reject(error);
+ }
+ } catch (error2) {
+ subscriber.reject(error2);
}
}
- };
- const initializeCastApi = () => {
- return new Promise((resolve, reject) => {
- window.__onGCastApiAvailable = (isAvailable) => {
- if (isAvailable) {
- const context = window.cast.framework.CastContext.getInstance();
- context.setOptions({
- receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
- autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
- });
- context.addEventListener(
- window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
- (event) => {
- const SessionState = window.cast.framework.SessionState;
- castState = event.sessionState;
- castSession = event.session;
- switch (event.sessionState) {
- case SessionState.NO_SESSION:
- option.onStateChange?.("disconnected");
- updateCastButton("disconnected");
- break;
- case SessionState.SESSION_STARTING:
- option.onStateChange?.("connecting");
- updateCastButton("connecting");
- break;
- case SessionState.SESSION_STARTED:
- option.onStateChange?.("connected");
- updateCastButton("connected");
- break;
- case SessionState.SESSION_ENDING:
- option.onStateChange?.("disconnecting");
- updateCastButton("disconnecting");
- break;
- case SessionState.SESSION_RESUMED:
- option.onStateChange?.("connected");
- updateCastButton("connected");
- break;
- }
- }
- );
- context.addEventListener(window.cast.framework.CastContextEventType.CAST_STATE_CHANGED, (event) => {
- const CastState = window.cast.framework.CastState;
- switch (event.castState) {
- case CastState.NO_DEVICES_AVAILABLE:
- option.onCastAvailable?.(false);
- break;
- case CastState.NOT_CONNECTED:
- option.onCastAvailable?.(true);
- break;
- case CastState.CONNECTING:
- case CastState.CONNECTED:
- option.onCastAvailable?.(true);
- break;
- }
- });
- isCastInitialized = true;
- resolve();
- } else {
- reject(new Error("Cast API is not available"));
- }
- };
- if (!window.chrome || !window.chrome.cast) {
- loadScript(option.sdk || DEFAULT_SDK).catch(reject);
- }
- });
- };
- const castVideo = (art, session) => {
- const url = option.url || art.option.url;
- const mediaInfo = new window.chrome.cast.media.MediaInfo(url, option.mimeType || getMimeType(url));
- const request = new window.chrome.cast.media.LoadRequest(mediaInfo);
- session.loadMedia(request).then(() => {
- art.notice.show = "Casting started";
- option.onCastStart?.();
- }).catch((error) => {
- art.notice.show = "Error casting media";
- option.onError?.(error);
- throw error;
- });
- };
- return async (art) => {
- art.controls.add({
- name: "chromecast",
- position: "right",
- tooltip: "Chromecast",
- html: `${option.icon || DEFAULT_ICON}`,
- click: async () => {
- if (!isCastInitialized) {
- try {
- await initializeCastApi();
- } catch (error) {
- art.notice.show = "Failed to initialize Cast API";
- option.onError?.(error);
- throw error;
- }
- }
- const context = window.cast.framework.CastContext.getInstance();
- if (castSession) {
- castVideo(art, castSession);
- } else {
- try {
- const session = await context.requestSession();
- castVideo(art, session);
- } catch (error) {
- art.notice.show = "Error connecting to cast session";
- option.onError?.(error);
- throw error;
- }
- }
- }
- });
- return {
- name: "artplayerPluginChromecast",
- getCastState: () => castState,
- isCasting: () => castSession !== null
+ loading.subscribers.clear();
+ }
+ function available(value, errorInfo) {
+ try {
+ previous?.call(host, value, errorInfo);
+ } finally {
+ const sdk = value ? ready(host) : void 0;
+ finish(sdk, new Error("Cast API is not available"));
+ }
+ }
+ host.__onGCastApiAvailable = available;
+ try {
+ timer = host.setTimeout(() => finish(void 0, new Error("Timed out initializing Cast API")), 3e4);
+ script = document.createElement("script");
+ script.src = src || DEFAULT_SDK;
+ script.onerror = (error) => finish(void 0, error);
+ script.onload = () => {
+ const sdk = ready(host);
+ if (sdk)
+ finish(sdk);
};
- };
+ document.body.appendChild(script);
+ } catch (error) {
+ finish(void 0, error);
+ }
+ return subscription;
}
+function createController(art, option) {
+ let disposed = false;
+ let sdk;
+ let context;
+ let session = null;
+ let state = null;
+ let activeClick;
+ let releaseLoader;
+ let generation = 0;
+ let operationStarted = false;
+ let cancelOperation;
+ let cancel;
+ const cancelled = new Promise((resolve) => {
+ cancel = () => resolve(void 0);
+ });
+ let element;
+ const active = () => !disposed && !art.isDestroy;
+ const update = (value) => {
+ option.onStateChange?.(value);
+ if (!active())
+ return;
+ const button = element?.querySelector(".art-icon-cast");
+ if (button)
+ button.style.color = value === "connected" ? "red" : value === "connecting" || value === "disconnecting" ? "orange" : "white";
+ };
+ const onSession = (event) => {
+ if (!active() || !sdk)
+ return;
+ state = event.sessionState;
+ const states = sdk.framework.SessionState;
+ if (state === states.NO_SESSION || state === states.SESSION_ENDED || state === states.SESSION_START_FAILED) {
+ if (state !== states.NO_SESSION || operationStarted) {
+ generation++;
+ cancelOperation?.();
+ }
+ session = null;
+ update("disconnected");
+ } else {
+ if (state === states.SESSION_ENDING || session && event.session && event.session !== session) {
+ generation++;
+ cancelOperation?.();
+ }
+ session = event.session || null;
+ if (state === states.SESSION_STARTING)
+ update("connecting");
+ else if (state === states.SESSION_ENDING)
+ update("disconnecting");
+ else if (state === states.SESSION_STARTED || state === states.SESSION_RESUMED)
+ update("connected");
+ }
+ };
+ const onAvailability = (event) => {
+ if (!active() || !sdk)
+ return;
+ const states = sdk.framework.CastState;
+ if (event.castState === states.NO_DEVICES_AVAILABLE)
+ option.onCastAvailable?.(false);
+ else if (event.castState === states.NOT_CONNECTED || event.castState === states.CONNECTING || event.castState === states.CONNECTED)
+ option.onCastAvailable?.(true);
+ };
+ const detach = () => {
+ const ownedContext = context;
+ const ownedSdk = sdk;
+ context = void 0;
+ sdk = void 0;
+ if (ownedContext && ownedSdk) {
+ const events = ownedSdk.framework.CastContextEventType;
+ const errors = [];
+ try {
+ ownedContext.removeEventListener(events.SESSION_STATE_CHANGED, onSession);
+ } catch (error) {
+ errors.push(error);
+ }
+ try {
+ ownedContext.removeEventListener(events.CAST_STATE_CHANGED, onAvailability);
+ } catch (error) {
+ errors.push(error);
+ }
+ if (errors.length)
+ throw errors[0];
+ }
+ };
+ const destroy = () => {
+ if (disposed)
+ return;
+ disposed = true;
+ cancel();
+ const release = releaseLoader;
+ releaseLoader = void 0;
+ const errors = [];
+ for (const cleanup of [release, detach, () => art.off("destroy", destroy)]) {
+ try {
+ cleanup?.();
+ } catch (error) {
+ errors.push(error);
+ }
+ }
+ session = null;
+ element = void 0;
+ if (errors.length)
+ throw errors[0];
+ };
+ async function cast() {
+ const epoch = generation;
+ const currentOperation = () => active() && generation === epoch;
+ let stop;
+ const superseded = new Promise((resolve) => {
+ stop = () => resolve(void 0);
+ });
+ cancelOperation = stop;
+ let failureNotice = "Failed to initialize Cast API";
+ try {
+ if (!sdk || !context) {
+ const loading = loadSdk(option.sdk, (available) => {
+ if (!currentOperation())
+ return;
+ sdk = available;
+ context = configure(sdk);
+ if (!currentOperation())
+ return;
+ const events = sdk.framework.CastContextEventType;
+ try {
+ context.addEventListener(events.SESSION_STATE_CHANGED, onSession);
+ if (active())
+ context.addEventListener(events.CAST_STATE_CHANGED, onAvailability);
+ } catch (error) {
+ try {
+ detach();
+ } catch {
+ }
+ throw error;
+ }
+ });
+ releaseLoader = loading.release;
+ await Promise.race([loading.promise, cancelled, superseded]);
+ loading.release();
+ releaseLoader = void 0;
+ }
+ if (!currentOperation() || !context || !sdk)
+ return;
+ failureNotice = "Error connecting to cast session";
+ operationStarted = true;
+ let current = context.getCurrentSession();
+ if (!currentOperation())
+ return;
+ if (!current) {
+ await Promise.race([context.requestSession(), cancelled, superseded]);
+ if (!currentOperation())
+ return;
+ current = context.getCurrentSession();
+ }
+ if (!currentOperation())
+ return;
+ if (!current)
+ throw new Error("No active Cast session");
+ session = current;
+ failureNotice = "Error casting media";
+ await Promise.race([loadMedia(sdk, current, option, art.option.url), cancelled, superseded]);
+ if (currentOperation()) {
+ art.notice.show = "Casting started";
+ option.onCastStart?.();
+ }
+ } catch (error) {
+ if (currentOperation()) {
+ art.notice.show = failureNotice;
+ option.onError?.(error);
+ throw error;
+ }
+ } finally {
+ releaseLoader?.();
+ releaseLoader = void 0;
+ if (cancelOperation === stop)
+ cancelOperation = void 0;
+ operationStarted = false;
+ }
+ }
+ const click = () => {
+ if (!active())
+ return Promise.resolve();
+ if (!activeClick) {
+ let resolve;
+ let reject;
+ activeClick = new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ activeClick.catch(() => {
+ });
+ cast().then(() => {
+ activeClick = void 0;
+ resolve();
+ }, (error) => {
+ activeClick = void 0;
+ reject(error);
+ });
+ }
+ return activeClick;
+ };
+ art.controls.add({
+ name: "chromecast",
+ position: "right",
+ tooltip: "Chromecast",
+ html: `${option.icon || icon}`,
+ mounted(control) {
+ if (active())
+ element = control;
+ },
+ click
+ });
+ art.on("destroy", destroy);
+ if (art.isDestroy)
+ destroy();
+ return { name: "artplayerPluginChromecast", getCastState: () => state, isCasting: () => session !== null };
+}
+function artplayerPluginChromecast(option) {
+ return async (art) => createController(art, option);
+}
+Object.defineProperty(artplayerPluginChromecast, "default", { value: artplayerPluginChromecast, writable: true, configurable: true });
export {
artplayerPluginChromecast as default
};
diff --git a/package.json b/package.json
index 5f9d7b7b4..4dcc16577 100644
--- a/package.json
+++ b/package.json
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js",
"typecheck": "node scripts/typecheck.mjs",
- "test:unit": "node --test test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js",
+ "test:unit": "node --test test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -97,7 +97,7 @@
"test:asr": "node --test test/asr.test.js test/asr-audio.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-distribution.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js",
"test:asr-types-package": "node refactor/scripts/asr-package-types.mjs",
"test:asr-demo": "node refactor/scripts/asr-local-demo.mjs",
- "test:chromecast": "node --test test/chromecast.test.js test/chromecast-failures.test.js"
+ "test:chromecast": "node --test test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
diff --git a/packages/artplayer-plugin-chromecast/ARCHITECTURE.md b/packages/artplayer-plugin-chromecast/ARCHITECTURE.md
new file mode 100644
index 000000000..f554b25e5
--- /dev/null
+++ b/packages/artplayer-plugin-chromecast/ARCHITECTURE.md
@@ -0,0 +1,131 @@
+# Chromecast maintenance
+
+The factory remains `artplayerPluginChromecast(option)`. Registration is async and
+adds the `chromecast` right control immediately. Its resolved object retains
+`name`, `getCastState()` and `isCasting()`. SDK loading remains lazy on first click.
+The option object stays live: later changes to `url`, `mimeType`, `sdk` and callback
+properties are read at the corresponding operation. Callbacks retain `this === option`.
+The control HTML is captured at registration, as before.
+
+## Modules
+
+| File | Responsibility |
+| --- | --- |
+| `src/index.ts` | Public factory and historical `.default` factory alias |
+| `src/controller.ts` | One registration's subscriptions, raw session state, click operation, notices and owned control icon |
+| `src/sdk.ts` | Framework readiness, shared in-flight script leases, callback preservation and context configuration |
+| `src/media.ts` | Existing URL/extension MIME rules and SDK media request construction |
+| `src/types.ts` | Narrow internal SDK, event and callback contracts; not the published declaration entrypoint |
+| `src/cast.svg` | Original default Cast artwork imported as inline SVG |
+
+Dependencies point from the factory to the controller, then to the SDK loader and
+media builder. The loader and media builder do not receive an ArtPlayer instance.
+Public declarations remain in `types/`; their historical mismatch with async
+registration is tracked separately by PKG-CAST-04. Internal strict typing does not
+silently change those declarations.
+
+## Initialization and ownership
+
+Each registration has its own session state and subscriptions, even when one
+factory is reused for several players. Unclicked instances do not subscribe to
+the SDK. An initialized instance observes page-wide CastContext events; this is
+the SDK's shared session, not an exclusively owned receiver session.
+
+Within a loaded plugin module, controllers share one pending SDK load per window.
+The first pending request's SDK URL wins; another URL can be used on a later retry.
+A complete existing Framework is reused immediately. A partial `chrome.cast`
+object is insufficient readiness and still permits loading the Framework script.
+The original availability callback is invoked with its window receiver and both
+arguments. Its thrown exception still escapes to its caller, while the plugin's
+own subscribers settle independently. Cleanup restores that handler only while
+our handler is still installed, so a later external replacement is preserved.
+
+On availability, each subscriber installs its listeners synchronously before its
+loader promise resolves. This preserves the historical boundary where a session
+event immediately following `__onGCastApiAvailable(true)` is observable. One
+subscriber's setup exception rejects that subscriber without stranding the rest.
+The default receiver and ORIGIN_SCOPED policy are configured once per observed
+context. This does not negotiate receiver configuration with unrelated Cast apps.
+
+Successful scripts remain on the page. Failed or last-owner-cancelled scripts are
+removed and can be retried. Script `load` may precede Framework availability, so
+incomplete load does not fail immediately; a 30-second readiness timeout settles
+an unavailable Framework even if no error/availability event arrives. The timeout
+is cleared on success, failure and last-owner cancellation. Destroying one player
+releases its lease without interrupting another pending player. Independently
+loaded copies of this plugin do not share their module-local lease registry.
+
+## Session operations and failure semantics
+
+One player has at most one pending click operation; concurrent clicks receive the
+same promise. After initialization, the controller reads `getCurrentSession()`.
+If absent, it awaits `requestSession()` and reads `getCurrentSession()` again.
+The request's completion value is not a CastSession. No active session after a
+successful request is a connection error. Media URL selection uses the current
+`option.url || art.option.url`; no receiver URL rewriting is performed.
+
+The complete media load is awaited. The click promise rejects with the original
+SDK error and reports one stage-specific notice and `onError`. A separate rejection
+observer handles ArtPlayer's DOM dispatcher, which ignores returned click promises;
+programmatic callers still receive the original rejecting promise. Callback
+exceptions retain their normal propagation and are not interpreted as receiver
+success evidence.
+
+`getCastState()` retains the last raw SDK SessionState, initially null, and is not
+the normalized callback state. `isCasting()` reflects a retained session reference,
+including one acquired by a successful session request; it does not prove receiver
+playback. `NO_SESSION`, `SESSION_ENDED` and `SESSION_START_FAILED` clear that reference
+and notify disconnected. Initial `NO_SESSION` during SDK setup does not cancel the
+first click before a session operation begins. Ending/terminal events during a
+request or media load, and replacement of the current session, invalidate pending
+work. Late success/rejection is observed without stale media starts or notices.
+
+Destroy marks the controller inactive, settles its pending click, releases its
+loader lease, attempts both SDK listener removals and removes its ArtPlayer
+subscription. All cleanup is attempted even when one SDK removal throws; the first
+cleanup error is preserved. Any callback left behind by a broken SDK removal is
+inactive. The plugin never ends a page-shared session. ArtPlayer owns control DOM
+removal; the plugin only updates the icon under the element supplied to its own
+control's `mounted` callback, retaining `.art-icon.art-icon-cast` and the
+red/orange/white states. The actual `Control.add` override returns undefined even
+though the shared component superclass returns an element; never rely on the
+control add return value for DOM ownership. Candidate fixtures replay `mounted`
+and preserve this undefined return so the browser-discovered regression remains
+covered outside the browser too.
+
+## Artwork provenance
+
+The default SVG now uses original rectangle, circular arc and dot geometry authored
+for this refactor under the repository MIT license. It does not copy the former
+Font Awesome Pro path. `src/cast.svg` is the single artwork source; custom `icon`
+HTML and the existing wrapper hooks remain supported. The previous commercial
+notice/source gap is tracked as VENDOR-10; production bundles must be regenerated
+from this source rather than edited by hand.
+
+## Validation and next changes
+
+Use the pinned Node and Yarn versions. Scoped commands from the repository root:
+
+```sh
+node --test test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js
+yarn exec tsc -p packages/artplayer-plugin-chromecast/tsconfig.json
+yarn exec eslint packages/artplayer-plugin-chromecast/src test/chromecast-runtime.test.js
+yarn build artplayer-plugin-chromecast
+```
+
+`ARTPLAYER_CHROMECAST_ARTIFACT` selects a built main or legacy file for the candidate
+tests. `ARTPLAYER_CHROMECAST_ESM_ARTIFACT` selects the native ESM artifact for the
+default-export identity check. Without these variables tests bundle current source
+in memory. Historical tests retain actual frozen npm/source inputs and deliberately
+observe the old defects; candidate tests assert their corrections separately.
+
+Change SDK loading in `sdk.ts`, state/cleanup in `controller.ts`, and MIME behavior
+in `media.ts`. Always retain synchronous availability-event coverage, ignored DOM
+promise rejection coverage, multi-player ownership, partial failure cleanup and
+late completion tests when changing those boundaries.
+
+Controlled SDK tests and local browser DOM interaction cannot establish real Cast
+receiver playback. PKG-CAST-05 still requires the supported Chrome/HTTPS sender,
+actual receiver, network-reachable media, source changes and disconnection evidence.
+The default SDK URL is mutable; no remote SDK version, physical receiver or Safari
+support is established by this source migration.
diff --git a/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.js b/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.js
index f6a65e412..ee2470036 100644
--- a/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.js
+++ b/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.js
@@ -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?(t.artplayerPluginChromecast=t(),define(function(){return t.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";return function(e){let t=!1,o=null,n=null;const a=e=>{const t=document.querySelector(".art-icon-cast");if(t)switch(e){case"connected":t.style.color="red";break;case"connecting":case"disconnecting":t.style.color="orange";break;default:t.style.color="white"}},c=()=>new Promise((c,s)=>{var i;window.__onGCastApiAvailable=i=>{if(i){const s=window.cast.framework.CastContext.getInstance();s.setOptions({receiverApplicationId:window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED}),s.addEventListener(window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,t=>{const c=window.cast.framework.SessionState;switch(n=t.sessionState,o=t.session,t.sessionState){case c.NO_SESSION:e.onStateChange?.("disconnected"),a("disconnected");break;case c.SESSION_STARTING:e.onStateChange?.("connecting"),a("connecting");break;case c.SESSION_STARTED:e.onStateChange?.("connected"),a("connected");break;case c.SESSION_ENDING:e.onStateChange?.("disconnecting"),a("disconnecting");break;case c.SESSION_RESUMED:e.onStateChange?.("connected"),a("connected")}}),s.addEventListener(window.cast.framework.CastContextEventType.CAST_STATE_CHANGED,t=>{const o=window.cast.framework.CastState;switch(t.castState){case o.NO_DEVICES_AVAILABLE:e.onCastAvailable?.(!1);break;case o.NOT_CONNECTED:case o.CONNECTING:case o.CONNECTED:e.onCastAvailable?.(!0)}}),t=!0,c()}else s(new Error("Cast API is not available"))},window.chrome&&window.chrome.cast||(i=e.sdk||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",new Promise((e,t)=>{const o=document.createElement("script");o.src=i,o.onload=e,o.onerror=t,document.body.appendChild(o)})).catch(s)}),s=(t,o)=>{const n=e.url||t.option.url,a=new window.chrome.cast.media.MediaInfo(n,e.mimeType||function(e){return{mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"}[e.split("?")[0].split("#")[0].split(".").pop().toLowerCase()]||"application/octet-stream"}(n)),c=new window.chrome.cast.media.LoadRequest(a);o.loadMedia(c).then(()=>{t.notice.show="Casting started",e.onCastStart?.()}).catch(o=>{throw t.notice.show="Error casting media",e.onError?.(o),o})};return async a=>(a.controls.add({name:"chromecast",position:"right",tooltip:"Chromecast",html:`${e.icon||''}`,click:async()=>{if(!t)try{await c()}catch(i){throw a.notice.show="Failed to initialize Cast API",e.onError?.(i),i}const n=window.cast.framework.CastContext.getInstance();if(o)s(a,o);else try{const e=await n.requestSession();s(a,e)}catch(i){throw a.notice.show="Error connecting to cast session",e.onError?.(i),i}}}),{name:"artplayerPluginChromecast",getCastState:()=>n,isCasting:()=>null!==o})}});
+!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginChromecast=t(),define(function(){return e.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";const e={mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"};function t(t,o,n,i){const r=n.url||i,s=r.split("?")[0].split("#")[0].split(".").pop().toLowerCase(),a=new t.media.MediaInfo(r,n.mimeType||e[s]||"application/octet-stream");return o.loadMedia(new t.media.LoadRequest(a))}const o=new WeakMap,n=new WeakSet;function i(e){const t=e.cast?.framework,o=e.chrome?.cast;if("function"===typeof t?.CastContext?.getInstance&&t.CastContextEventType&&t.SessionState&&t.CastState&&"function"===typeof o?.media?.MediaInfo&&"function"===typeof o.media.LoadRequest&&o.AutoJoinPolicy)return{framework:t,media:o.media,autoJoinPolicy:o.AutoJoinPolicy.ORIGIN_SCOPED}}function r(e,t){let o;const n=new Promise((n,i)=>{o={ready:t,resolve:n,reject:i},e.subscribers.add(o)});let i=!1;return{promise:n,release:()=>{i||(i=!0,e.subscribers.delete(o),o.reject(new Error("Cast initialization cancelled")),e.subscribers.size||e.cancel())}}}function s(e,s){let a,c,l,d,u,f,m=!1,v=null,S=null,p=0,E=!1;const w=new Promise(e=>{f=()=>e(void 0)});let C;const h=()=>!m&&!e.isDestroy,y=e=>{if(s.onStateChange?.(e),!h())return;const t=C?.querySelector(".art-icon-cast");t&&(t.style.color="connected"===e?"red":"connecting"===e||"disconnecting"===e?"orange":"white")},g=e=>{if(!h()||!a)return;S=e.sessionState;const t=a.framework.SessionState;S===t.NO_SESSION||S===t.SESSION_ENDED||S===t.SESSION_START_FAILED?((S!==t.NO_SESSION||E)&&(p++,u?.()),v=null,y("disconnected")):((S===t.SESSION_ENDING||v&&e.session&&e.session!==v)&&(p++,u?.()),v=e.session||null,S===t.SESSION_STARTING?y("connecting"):S===t.SESSION_ENDING?y("disconnecting"):S!==t.SESSION_STARTED&&S!==t.SESSION_RESUMED||y("connected"))},A=e=>{if(!h()||!a)return;const t=a.framework.CastState;e.castState===t.NO_DEVICES_AVAILABLE?s.onCastAvailable?.(!1):e.castState!==t.NOT_CONNECTED&&e.castState!==t.CONNECTING&&e.castState!==t.CONNECTED||s.onCastAvailable?.(!0)},_=()=>{const e=c,t=a;if(c=void 0,a=void 0,e&&t){const n=t.framework.CastContextEventType,i=[];try{e.removeEventListener(n.SESSION_STATE_CHANGED,g)}catch(o){i.push(o)}try{e.removeEventListener(n.CAST_STATE_CHANGED,A)}catch(o){i.push(o)}if(i.length)throw i[0]}},N=()=>{if(m)return;m=!0,f();const t=d;d=void 0;const o=[];for(const i of[t,_,()=>e.off("destroy",N)])try{i?.()}catch(n){o.push(n)}if(v=null,C=void 0,o.length)throw o[0]};async function I(){const l=p,f=()=>h()&&p===l;let m;const S=new Promise(e=>{m=()=>e(void 0)});u=m;let C="Failed to initialize Cast API";try{if(!a||!c){const e=function(e,t){const n=window,s=i(n);if(s)return{promise:new Promise((e,o)=>{try{t(s),e(s)}catch(n){o(n)}}),release:()=>{}};const a=o.get(n);if(a)return r(a,t);const c={subscribers:new Set,cancel:()=>v(void 0,new Error("Cast initialization cancelled"))};o.set(n,c);const l=r(c,t),d=n.__onGCastApiAvailable;let u,f,m=!1;function v(e,t){if(!m){m=!0,void 0!==f&&n.clearTimeout(f),o.delete(n),n.__onGCastApiAvailable===S&&(n.__onGCastApiAvailable=d),u&&(u.onload=null,u.onerror=null,e||u.remove());for(const o of c.subscribers)try{e?(o.ready(e),o.resolve(e)):o.reject(t)}catch(i){o.reject(i)}c.subscribers.clear()}}function S(e,t){try{d?.call(n,e,t)}finally{v(e?i(n):void 0,new Error("Cast API is not available"))}}n.__onGCastApiAvailable=S;try{f=n.setTimeout(()=>v(void 0,new Error("Timed out initializing Cast API")),3e4),u=document.createElement("script"),u.src=e||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",u.onerror=e=>v(void 0,e),u.onload=()=>{const e=i(n);e&&v(e)},document.body.appendChild(u)}catch(p){v(void 0,p)}return l}(s.sdk,e=>{if(!f())return;if(a=e,c=function(e){const t=e.framework.CastContext.getInstance();return n.has(t)||(t.setOptions({receiverApplicationId:e.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:e.autoJoinPolicy}),n.add(t)),t}(a),!f())return;const t=a.framework.CastContextEventType;try{c.addEventListener(t.SESSION_STATE_CHANGED,g),h()&&c.addEventListener(t.CAST_STATE_CHANGED,A)}catch(o){try{_()}catch{}throw o}});d=e.release,await Promise.race([e.promise,w,S]),e.release(),d=void 0}if(!f()||!c||!a)return;C="Error connecting to cast session",E=!0;let l=c.getCurrentSession();if(!f())return;if(!l){if(await Promise.race([c.requestSession(),w,S]),!f())return;l=c.getCurrentSession()}if(!f())return;if(!l)throw new Error("No active Cast session");v=l,C="Error casting media",await Promise.race([t(a,l,s,e.option.url),w,S]),f()&&(e.notice.show="Casting started",s.onCastStart?.())}catch(y){if(f())throw e.notice.show=C,s.onError?.(y),y}finally{d?.(),d=void 0,u===m&&(u=void 0),E=!1}}return e.controls.add({name:"chromecast",position:"right",tooltip:"Chromecast",html:`${s.icon||'\r\n'}`,mounted(e){h()&&(C=e)},click:()=>{if(!h())return Promise.resolve();if(!l){let e,t;l=new Promise((o,n)=>{e=o,t=n}),l.catch(()=>{}),I().then(()=>{l=void 0,e()},e=>{l=void 0,t(e)})}return l}}),e.on("destroy",N),e.isDestroy&&N(),{name:"artplayerPluginChromecast",getCastState:()=>S,isCasting:()=>null!==v}}function a(e){return async t=>s(t,e)}return Object.defineProperty(a,"default",{value:a,writable:!0,configurable:!0}),a});
diff --git a/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.legacy.js b/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.legacy.js
index a9bf8d244..b53bb0487 100644
--- a/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.legacy.js
+++ b/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.legacy.js
@@ -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?(t.artplayerPluginChromecast=t(),define(function(){return t.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";var e=(e,t,n)=>new Promise((o,a)=>{var c=e=>{try{i(n.next(e))}catch(t){a(t)}},s=e=>{try{i(n.throw(e))}catch(t){a(t)}},i=e=>e.done?o(e.value):Promise.resolve(e.value).then(c,s);i((n=n.apply(e,t)).next())});return function(t){let n=!1,o=null,a=null;const c=e=>{const t=document.querySelector(".art-icon-cast");if(t)switch(e){case"connected":t.style.color="red";break;case"connecting":case"disconnecting":t.style.color="orange";break;default:t.style.color="white"}},s=()=>new Promise((e,s)=>{var i;window.__onGCastApiAvailable=i=>{if(i){const s=window.cast.framework.CastContext.getInstance();s.setOptions({receiverApplicationId:window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED}),s.addEventListener(window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,e=>{var n,s,i,l,r;const d=window.cast.framework.SessionState;switch(a=e.sessionState,o=e.session,e.sessionState){case d.NO_SESSION:null==(n=t.onStateChange)||n.call(t,"disconnected"),c("disconnected");break;case d.SESSION_STARTING:null==(s=t.onStateChange)||s.call(t,"connecting"),c("connecting");break;case d.SESSION_STARTED:null==(i=t.onStateChange)||i.call(t,"connected"),c("connected");break;case d.SESSION_ENDING:null==(l=t.onStateChange)||l.call(t,"disconnecting"),c("disconnecting");break;case d.SESSION_RESUMED:null==(r=t.onStateChange)||r.call(t,"connected"),c("connected")}}),s.addEventListener(window.cast.framework.CastContextEventType.CAST_STATE_CHANGED,e=>{var n,o,a;const c=window.cast.framework.CastState;switch(e.castState){case c.NO_DEVICES_AVAILABLE:null==(n=t.onCastAvailable)||n.call(t,!1);break;case c.NOT_CONNECTED:null==(o=t.onCastAvailable)||o.call(t,!0);break;case c.CONNECTING:case c.CONNECTED:null==(a=t.onCastAvailable)||a.call(t,!0)}}),n=!0,e()}else s(new Error("Cast API is not available"))},window.chrome&&window.chrome.cast||(i=t.sdk||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",new Promise((e,t)=>{const n=document.createElement("script");n.src=i,n.onload=e,n.onerror=t,document.body.appendChild(n)})).catch(s)}),i=(e,n)=>{const o=t.url||e.option.url,a=new window.chrome.cast.media.MediaInfo(o,t.mimeType||function(e){return{mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"}[e.split("?")[0].split("#")[0].split(".").pop().toLowerCase()]||"application/octet-stream"}(o)),c=new window.chrome.cast.media.LoadRequest(a);n.loadMedia(c).then(()=>{var n;e.notice.show="Casting started",null==(n=t.onCastStart)||n.call(t)}).catch(n=>{var o;throw e.notice.show="Error casting media",null==(o=t.onError)||o.call(t,n),n})};return c=>e(null,null,function*(){return c.controls.add({name:"chromecast",position:"right",tooltip:"Chromecast",html:`${t.icon||''}`,click:()=>e(null,null,function*(){var e,a;if(!n)try{yield s()}catch(r){throw c.notice.show="Failed to initialize Cast API",null==(e=t.onError)||e.call(t,r),r}const l=window.cast.framework.CastContext.getInstance();if(o)i(c,o);else try{const e=yield l.requestSession();i(c,e)}catch(r){throw c.notice.show="Error connecting to cast session",null==(a=t.onError)||a.call(t,r),r}})}),{name:"artplayerPluginChromecast",getCastState:()=>a,isCasting:()=>null!==o}})}});
+!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginChromecast=t(),define(function(){return e.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";var e=(e,t,o)=>new Promise((n,i)=>{var r=e=>{try{a(o.next(e))}catch(t){i(t)}},s=e=>{try{a(o.throw(e))}catch(t){i(t)}},a=e=>e.done?n(e.value):Promise.resolve(e.value).then(r,s);a((o=o.apply(e,t)).next())});const t={mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"};function o(e,o,n,i){const r=n.url||i,s=r.split("?")[0].split("#")[0].split(".").pop().toLowerCase(),a=new e.media.MediaInfo(r,n.mimeType||t[s]||"application/octet-stream");return o.loadMedia(new e.media.LoadRequest(a))}const n=new WeakMap,i=new WeakSet;function r(e){var t,o,n,i;const r=null==(t=e.cast)?void 0:t.framework,s=null==(o=e.chrome)?void 0:o.cast;if("function"===typeof(null==(n=null==r?void 0:r.CastContext)?void 0:n.getInstance)&&r.CastContextEventType&&r.SessionState&&r.CastState&&"function"===typeof(null==(i=null==s?void 0:s.media)?void 0:i.MediaInfo)&&"function"===typeof s.media.LoadRequest&&s.AutoJoinPolicy)return{framework:r,media:s.media,autoJoinPolicy:s.AutoJoinPolicy.ORIGIN_SCOPED}}function s(e,t){let o;const n=new Promise((n,i)=>{o={ready:t,resolve:n,reject:i},e.subscribers.add(o)});let i=!1;return{promise:n,release:()=>{i||(i=!0,e.subscribers.delete(o),o.reject(new Error("Cast initialization cancelled")),e.subscribers.size||e.cancel())}}}function a(t,a){let l,c,u,d,v,f,m=!1,S=null,p=null,E=0,C=!1;const w=new Promise(e=>{f=()=>e(void 0)});let h;const y=()=>!m&&!t.isDestroy,g=e=>{var t;if(null==(t=a.onStateChange)||t.call(a,e),!y())return;const o=null==h?void 0:h.querySelector(".art-icon-cast");o&&(o.style.color="connected"===e?"red":"connecting"===e||"disconnecting"===e?"orange":"white")},A=e=>{if(!y()||!l)return;p=e.sessionState;const t=l.framework.SessionState;p===t.NO_SESSION||p===t.SESSION_ENDED||p===t.SESSION_START_FAILED?((p!==t.NO_SESSION||C)&&(E++,null==v||v()),S=null,g("disconnected")):((p===t.SESSION_ENDING||S&&e.session&&e.session!==S)&&(E++,null==v||v()),S=e.session||null,p===t.SESSION_STARTING?g("connecting"):p===t.SESSION_ENDING?g("disconnecting"):p!==t.SESSION_STARTED&&p!==t.SESSION_RESUMED||g("connected"))},_=e=>{var t,o;if(!y()||!l)return;const n=l.framework.CastState;e.castState===n.NO_DEVICES_AVAILABLE?null==(t=a.onCastAvailable)||t.call(a,!1):e.castState!==n.NOT_CONNECTED&&e.castState!==n.CONNECTING&&e.castState!==n.CONNECTED||null==(o=a.onCastAvailable)||o.call(a,!0)},N=()=>{const e=c,t=l;if(c=void 0,l=void 0,e&&t){const n=t.framework.CastContextEventType,i=[];try{e.removeEventListener(n.SESSION_STATE_CHANGED,A)}catch(o){i.push(o)}try{e.removeEventListener(n.CAST_STATE_CHANGED,_)}catch(o){i.push(o)}if(i.length)throw i[0]}},I=()=>{if(m)return;m=!0,f();const e=d;d=void 0;const o=[];for(const i of[e,N,()=>t.off("destroy",I)])try{null==i||i()}catch(n){o.push(n)}if(S=null,h=void 0,o.length)throw o[0]};function T(){return e(this,null,function*(){var e,u;const f=E,m=()=>y()&&E===f;let p;const h=new Promise(e=>{p=()=>e(void 0)});v=p;let g="Failed to initialize Cast API";try{if(!l||!c){const e=function(e,t){const o=window,i=r(o);if(i)return{promise:new Promise((e,o)=>{try{t(i),e(i)}catch(n){o(n)}}),release:()=>{}};const a=n.get(o);if(a)return s(a,t);const l={subscribers:new Set,cancel:()=>m(void 0,new Error("Cast initialization cancelled"))};n.set(o,l);const c=s(l,t),u=o.__onGCastApiAvailable;let d,v,f=!1;function m(e,t){if(!f){f=!0,void 0!==v&&o.clearTimeout(v),n.delete(o),o.__onGCastApiAvailable===S&&(o.__onGCastApiAvailable=u),d&&(d.onload=null,d.onerror=null,e||d.remove());for(const o of l.subscribers)try{e?(o.ready(e),o.resolve(e)):o.reject(t)}catch(i){o.reject(i)}l.subscribers.clear()}}function S(e,t){try{null==u||u.call(o,e,t)}finally{m(e?r(o):void 0,new Error("Cast API is not available"))}}o.__onGCastApiAvailable=S;try{v=o.setTimeout(()=>m(void 0,new Error("Timed out initializing Cast API")),3e4),d=document.createElement("script"),d.src=e||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",d.onerror=e=>m(void 0,e),d.onload=()=>{const e=r(o);e&&m(e)},document.body.appendChild(d)}catch(p){m(void 0,p)}return c}(a.sdk,e=>{if(!m())return;if(l=e,c=function(e){const t=e.framework.CastContext.getInstance();return i.has(t)||(t.setOptions({receiverApplicationId:e.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:e.autoJoinPolicy}),i.add(t)),t}(l),!m())return;const t=l.framework.CastContextEventType;try{c.addEventListener(t.SESSION_STATE_CHANGED,A),y()&&c.addEventListener(t.CAST_STATE_CHANGED,_)}catch(o){try{N()}catch(n){}throw o}});d=e.release,yield Promise.race([e.promise,w,h]),e.release(),d=void 0}if(!m()||!c||!l)return;g="Error connecting to cast session",C=!0;let u=c.getCurrentSession();if(!m())return;if(!u){if(yield Promise.race([c.requestSession(),w,h]),!m())return;u=c.getCurrentSession()}if(!m())return;if(!u)throw new Error("No active Cast session");S=u,g="Error casting media",yield Promise.race([o(l,u,a,t.option.url),w,h]),m()&&(t.notice.show="Casting started",null==(e=a.onCastStart)||e.call(a))}catch(I){if(m())throw t.notice.show=g,null==(u=a.onError)||u.call(a,I),I}finally{null==d||d(),d=void 0,v===p&&(v=void 0),C=!1}})}return t.controls.add({name:"chromecast",position:"right",tooltip:"Chromecast",html:`${a.icon||'\r\n'}`,mounted(e){y()&&(h=e)},click:()=>{if(!y())return Promise.resolve();if(!u){let e,t;u=new Promise((o,n)=>{e=o,t=n}),u.catch(()=>{}),T().then(()=>{u=void 0,e()},e=>{u=void 0,t(e)})}return u}}),t.on("destroy",I),t.isDestroy&&I(),{name:"artplayerPluginChromecast",getCastState:()=>p,isCasting:()=>null!==S}}function l(t){return o=>e(null,null,function*(){return a(o,t)})}return Object.defineProperty(l,"default",{value:l,writable:!0,configurable:!0}),l});
diff --git a/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.mjs b/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.mjs
index 394e29f20..6d2367326 100644
--- a/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.mjs
+++ b/packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.mjs
@@ -4,173 +4,359 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
-function loadScript(src) {
- return new Promise((resolve, reject) => {
- const script = document.createElement("script");
- script.src = src;
- script.onload = resolve;
- script.onerror = reject;
- document.body.appendChild(script);
- });
-}
-function getMimeType(url) {
+const icon = '\r\n';
+const mimeTypes = {
+ mp4: "video/mp4",
+ webm: "video/webm",
+ ogg: "video/ogg",
+ ogv: "video/ogg",
+ mp3: "audio/mp3",
+ wav: "audio/wav",
+ flv: "video/x-flv",
+ mov: "video/quicktime",
+ avi: "video/x-msvideo",
+ wmv: "video/x-ms-wmv",
+ mpd: "application/dash+xml",
+ m3u8: "application/x-mpegURL"
+};
+function loadMedia(sdk, session, option, currentUrl) {
+ const url = option.url || currentUrl;
const extension = url.split("?")[0].split("#")[0].split(".").pop().toLowerCase();
- const mimeTypes = {
- mp4: "video/mp4",
- webm: "video/webm",
- ogg: "video/ogg",
- ogv: "video/ogg",
- mp3: "audio/mp3",
- wav: "audio/wav",
- flv: "video/x-flv",
- mov: "video/quicktime",
- avi: "video/x-msvideo",
- wmv: "video/x-ms-wmv",
- mpd: "application/dash+xml",
- m3u8: "application/x-mpegURL"
- };
- return mimeTypes[extension] || "application/octet-stream";
+ const info = new sdk.media.MediaInfo(url, option.mimeType || mimeTypes[extension] || "application/octet-stream");
+ return session.loadMedia(new sdk.media.LoadRequest(info));
}
-function artplayerPluginChromecast(option) {
- const DEFAULT_ICON = ``;
- const DEFAULT_SDK = "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
- let isCastInitialized = false;
- let castSession = null;
- let castState = null;
- const updateCastButton = (state) => {
- const button = document.querySelector(".art-icon-cast");
- if (button) {
- switch (state) {
- case "connected":
- button.style.color = "red";
- break;
- case "connecting":
- case "disconnecting":
- button.style.color = "orange";
- break;
- case "disconnected":
- default:
- button.style.color = "white";
- break;
+const DEFAULT_SDK = "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
+const pending = /* @__PURE__ */ new WeakMap();
+const configured = /* @__PURE__ */ new WeakSet();
+function ready(host) {
+ const framework = host.cast?.framework;
+ const api = host.chrome?.cast;
+ if (typeof framework?.CastContext?.getInstance === "function" && framework.CastContextEventType && framework.SessionState && framework.CastState && typeof api?.media?.MediaInfo === "function" && typeof api.media.LoadRequest === "function" && api.AutoJoinPolicy) {
+ return { framework, media: api.media, autoJoinPolicy: api.AutoJoinPolicy.ORIGIN_SCOPED };
+ }
+}
+function configure(sdk) {
+ const context = sdk.framework.CastContext.getInstance();
+ if (!configured.has(context)) {
+ context.setOptions({ receiverApplicationId: sdk.media.DEFAULT_MEDIA_RECEIVER_APP_ID, autoJoinPolicy: sdk.autoJoinPolicy });
+ configured.add(context);
+ }
+ return context;
+}
+function lease(loading, onReady) {
+ let subscriber;
+ const promise = new Promise((resolve, reject) => {
+ subscriber = { ready: onReady, resolve, reject };
+ loading.subscribers.add(subscriber);
+ });
+ let released = false;
+ return { promise, release: () => {
+ if (released)
+ return;
+ released = true;
+ loading.subscribers.delete(subscriber);
+ subscriber.reject(new Error("Cast initialization cancelled"));
+ if (!loading.subscribers.size)
+ loading.cancel();
+ } };
+}
+function loadSdk(src, onReady) {
+ const host = window;
+ const loaded = ready(host);
+ if (loaded) {
+ const promise = new Promise((resolve, reject) => {
+ try {
+ onReady(loaded);
+ resolve(loaded);
+ } catch (error) {
+ reject(error);
+ }
+ });
+ return { promise, release: () => {
+ } };
+ }
+ const existing = pending.get(host);
+ if (existing)
+ return lease(existing, onReady);
+ const loading = { subscribers: /* @__PURE__ */ new Set(), cancel: () => finish(void 0, new Error("Cast initialization cancelled")) };
+ pending.set(host, loading);
+ const subscription = lease(loading, onReady);
+ const previous = host.__onGCastApiAvailable;
+ let script;
+ let timer;
+ let settled = false;
+ function finish(sdk, error) {
+ if (settled)
+ return;
+ settled = true;
+ if (timer !== void 0)
+ host.clearTimeout(timer);
+ pending.delete(host);
+ if (host.__onGCastApiAvailable === available)
+ host.__onGCastApiAvailable = previous;
+ if (script) {
+ script.onload = null;
+ script.onerror = null;
+ if (!sdk)
+ script.remove();
+ }
+ for (const subscriber of loading.subscribers) {
+ try {
+ if (sdk) {
+ subscriber.ready(sdk);
+ subscriber.resolve(sdk);
+ } else {
+ subscriber.reject(error);
+ }
+ } catch (error2) {
+ subscriber.reject(error2);
}
}
- };
- const initializeCastApi = () => {
- return new Promise((resolve, reject) => {
- window.__onGCastApiAvailable = (isAvailable) => {
- if (isAvailable) {
- const context = window.cast.framework.CastContext.getInstance();
- context.setOptions({
- receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
- autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
- });
- context.addEventListener(
- window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
- (event) => {
- const SessionState = window.cast.framework.SessionState;
- castState = event.sessionState;
- castSession = event.session;
- switch (event.sessionState) {
- case SessionState.NO_SESSION:
- option.onStateChange?.("disconnected");
- updateCastButton("disconnected");
- break;
- case SessionState.SESSION_STARTING:
- option.onStateChange?.("connecting");
- updateCastButton("connecting");
- break;
- case SessionState.SESSION_STARTED:
- option.onStateChange?.("connected");
- updateCastButton("connected");
- break;
- case SessionState.SESSION_ENDING:
- option.onStateChange?.("disconnecting");
- updateCastButton("disconnecting");
- break;
- case SessionState.SESSION_RESUMED:
- option.onStateChange?.("connected");
- updateCastButton("connected");
- break;
- }
- }
- );
- context.addEventListener(window.cast.framework.CastContextEventType.CAST_STATE_CHANGED, (event) => {
- const CastState = window.cast.framework.CastState;
- switch (event.castState) {
- case CastState.NO_DEVICES_AVAILABLE:
- option.onCastAvailable?.(false);
- break;
- case CastState.NOT_CONNECTED:
- option.onCastAvailable?.(true);
- break;
- case CastState.CONNECTING:
- case CastState.CONNECTED:
- option.onCastAvailable?.(true);
- break;
- }
- });
- isCastInitialized = true;
- resolve();
- } else {
- reject(new Error("Cast API is not available"));
- }
- };
- if (!window.chrome || !window.chrome.cast) {
- loadScript(option.sdk || DEFAULT_SDK).catch(reject);
- }
- });
- };
- const castVideo = (art, session) => {
- const url = option.url || art.option.url;
- const mediaInfo = new window.chrome.cast.media.MediaInfo(url, option.mimeType || getMimeType(url));
- const request = new window.chrome.cast.media.LoadRequest(mediaInfo);
- session.loadMedia(request).then(() => {
- art.notice.show = "Casting started";
- option.onCastStart?.();
- }).catch((error) => {
- art.notice.show = "Error casting media";
- option.onError?.(error);
- throw error;
- });
- };
- return async (art) => {
- art.controls.add({
- name: "chromecast",
- position: "right",
- tooltip: "Chromecast",
- html: `${option.icon || DEFAULT_ICON}`,
- click: async () => {
- if (!isCastInitialized) {
- try {
- await initializeCastApi();
- } catch (error) {
- art.notice.show = "Failed to initialize Cast API";
- option.onError?.(error);
- throw error;
- }
- }
- const context = window.cast.framework.CastContext.getInstance();
- if (castSession) {
- castVideo(art, castSession);
- } else {
- try {
- const session = await context.requestSession();
- castVideo(art, session);
- } catch (error) {
- art.notice.show = "Error connecting to cast session";
- option.onError?.(error);
- throw error;
- }
- }
- }
- });
- return {
- name: "artplayerPluginChromecast",
- getCastState: () => castState,
- isCasting: () => castSession !== null
+ loading.subscribers.clear();
+ }
+ function available(value, errorInfo) {
+ try {
+ previous?.call(host, value, errorInfo);
+ } finally {
+ const sdk = value ? ready(host) : void 0;
+ finish(sdk, new Error("Cast API is not available"));
+ }
+ }
+ host.__onGCastApiAvailable = available;
+ try {
+ timer = host.setTimeout(() => finish(void 0, new Error("Timed out initializing Cast API")), 3e4);
+ script = document.createElement("script");
+ script.src = src || DEFAULT_SDK;
+ script.onerror = (error) => finish(void 0, error);
+ script.onload = () => {
+ const sdk = ready(host);
+ if (sdk)
+ finish(sdk);
};
- };
+ document.body.appendChild(script);
+ } catch (error) {
+ finish(void 0, error);
+ }
+ return subscription;
}
+function createController(art, option) {
+ let disposed = false;
+ let sdk;
+ let context;
+ let session = null;
+ let state = null;
+ let activeClick;
+ let releaseLoader;
+ let generation = 0;
+ let operationStarted = false;
+ let cancelOperation;
+ let cancel;
+ const cancelled = new Promise((resolve) => {
+ cancel = () => resolve(void 0);
+ });
+ let element;
+ const active = () => !disposed && !art.isDestroy;
+ const update = (value) => {
+ option.onStateChange?.(value);
+ if (!active())
+ return;
+ const button = element?.querySelector(".art-icon-cast");
+ if (button)
+ button.style.color = value === "connected" ? "red" : value === "connecting" || value === "disconnecting" ? "orange" : "white";
+ };
+ const onSession = (event) => {
+ if (!active() || !sdk)
+ return;
+ state = event.sessionState;
+ const states = sdk.framework.SessionState;
+ if (state === states.NO_SESSION || state === states.SESSION_ENDED || state === states.SESSION_START_FAILED) {
+ if (state !== states.NO_SESSION || operationStarted) {
+ generation++;
+ cancelOperation?.();
+ }
+ session = null;
+ update("disconnected");
+ } else {
+ if (state === states.SESSION_ENDING || session && event.session && event.session !== session) {
+ generation++;
+ cancelOperation?.();
+ }
+ session = event.session || null;
+ if (state === states.SESSION_STARTING)
+ update("connecting");
+ else if (state === states.SESSION_ENDING)
+ update("disconnecting");
+ else if (state === states.SESSION_STARTED || state === states.SESSION_RESUMED)
+ update("connected");
+ }
+ };
+ const onAvailability = (event) => {
+ if (!active() || !sdk)
+ return;
+ const states = sdk.framework.CastState;
+ if (event.castState === states.NO_DEVICES_AVAILABLE)
+ option.onCastAvailable?.(false);
+ else if (event.castState === states.NOT_CONNECTED || event.castState === states.CONNECTING || event.castState === states.CONNECTED)
+ option.onCastAvailable?.(true);
+ };
+ const detach = () => {
+ const ownedContext = context;
+ const ownedSdk = sdk;
+ context = void 0;
+ sdk = void 0;
+ if (ownedContext && ownedSdk) {
+ const events = ownedSdk.framework.CastContextEventType;
+ const errors = [];
+ try {
+ ownedContext.removeEventListener(events.SESSION_STATE_CHANGED, onSession);
+ } catch (error) {
+ errors.push(error);
+ }
+ try {
+ ownedContext.removeEventListener(events.CAST_STATE_CHANGED, onAvailability);
+ } catch (error) {
+ errors.push(error);
+ }
+ if (errors.length)
+ throw errors[0];
+ }
+ };
+ const destroy = () => {
+ if (disposed)
+ return;
+ disposed = true;
+ cancel();
+ const release = releaseLoader;
+ releaseLoader = void 0;
+ const errors = [];
+ for (const cleanup of [release, detach, () => art.off("destroy", destroy)]) {
+ try {
+ cleanup?.();
+ } catch (error) {
+ errors.push(error);
+ }
+ }
+ session = null;
+ element = void 0;
+ if (errors.length)
+ throw errors[0];
+ };
+ async function cast() {
+ const epoch = generation;
+ const currentOperation = () => active() && generation === epoch;
+ let stop;
+ const superseded = new Promise((resolve) => {
+ stop = () => resolve(void 0);
+ });
+ cancelOperation = stop;
+ let failureNotice = "Failed to initialize Cast API";
+ try {
+ if (!sdk || !context) {
+ const loading = loadSdk(option.sdk, (available) => {
+ if (!currentOperation())
+ return;
+ sdk = available;
+ context = configure(sdk);
+ if (!currentOperation())
+ return;
+ const events = sdk.framework.CastContextEventType;
+ try {
+ context.addEventListener(events.SESSION_STATE_CHANGED, onSession);
+ if (active())
+ context.addEventListener(events.CAST_STATE_CHANGED, onAvailability);
+ } catch (error) {
+ try {
+ detach();
+ } catch {
+ }
+ throw error;
+ }
+ });
+ releaseLoader = loading.release;
+ await Promise.race([loading.promise, cancelled, superseded]);
+ loading.release();
+ releaseLoader = void 0;
+ }
+ if (!currentOperation() || !context || !sdk)
+ return;
+ failureNotice = "Error connecting to cast session";
+ operationStarted = true;
+ let current = context.getCurrentSession();
+ if (!currentOperation())
+ return;
+ if (!current) {
+ await Promise.race([context.requestSession(), cancelled, superseded]);
+ if (!currentOperation())
+ return;
+ current = context.getCurrentSession();
+ }
+ if (!currentOperation())
+ return;
+ if (!current)
+ throw new Error("No active Cast session");
+ session = current;
+ failureNotice = "Error casting media";
+ await Promise.race([loadMedia(sdk, current, option, art.option.url), cancelled, superseded]);
+ if (currentOperation()) {
+ art.notice.show = "Casting started";
+ option.onCastStart?.();
+ }
+ } catch (error) {
+ if (currentOperation()) {
+ art.notice.show = failureNotice;
+ option.onError?.(error);
+ throw error;
+ }
+ } finally {
+ releaseLoader?.();
+ releaseLoader = void 0;
+ if (cancelOperation === stop)
+ cancelOperation = void 0;
+ operationStarted = false;
+ }
+ }
+ const click = () => {
+ if (!active())
+ return Promise.resolve();
+ if (!activeClick) {
+ let resolve;
+ let reject;
+ activeClick = new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ activeClick.catch(() => {
+ });
+ cast().then(() => {
+ activeClick = void 0;
+ resolve();
+ }, (error) => {
+ activeClick = void 0;
+ reject(error);
+ });
+ }
+ return activeClick;
+ };
+ art.controls.add({
+ name: "chromecast",
+ position: "right",
+ tooltip: "Chromecast",
+ html: `${option.icon || icon}`,
+ mounted(control) {
+ if (active())
+ element = control;
+ },
+ click
+ });
+ art.on("destroy", destroy);
+ if (art.isDestroy)
+ destroy();
+ return { name: "artplayerPluginChromecast", getCastState: () => state, isCasting: () => session !== null };
+}
+function artplayerPluginChromecast(option) {
+ return async (art) => createController(art, option);
+}
+Object.defineProperty(artplayerPluginChromecast, "default", { value: artplayerPluginChromecast, writable: true, configurable: true });
export {
artplayerPluginChromecast as default
};
diff --git a/packages/artplayer-plugin-chromecast/src/cast.svg b/packages/artplayer-plugin-chromecast/src/cast.svg
index 524252e03..a98d31106 100644
--- a/packages/artplayer-plugin-chromecast/src/cast.svg
+++ b/packages/artplayer-plugin-chromecast/src/cast.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/packages/artplayer-plugin-chromecast/src/controller.ts b/packages/artplayer-plugin-chromecast/src/controller.ts
new file mode 100644
index 000000000..c1964743d
--- /dev/null
+++ b/packages/artplayer-plugin-chromecast/src/controller.ts
@@ -0,0 +1,233 @@
+import type Artplayer from 'artplayer'
+import type { AvailabilityEvent, CastContext, CastSdk, CastSession, ChromecastOptions, ConnectionState, SessionEvent } from './types'
+import icon from './cast.svg?raw'
+import { loadMedia } from './media'
+import { configure, loadSdk } from './sdk'
+
+export function createController(art: Artplayer, option: ChromecastOptions) {
+ let disposed = false
+ let sdk: CastSdk | undefined
+ let context: CastContext | undefined
+ let session: CastSession | null = null
+ let state: string | null = null
+ let activeClick: Promise | undefined
+ let releaseLoader: (() => void) | undefined
+ let generation = 0
+ let operationStarted = false
+ let cancelOperation: (() => void) | undefined
+ let cancel!: () => void
+ const cancelled = new Promise((resolve) => {
+ cancel = () => resolve(undefined)
+ })
+ let element: HTMLElement | undefined
+ const active = () => !disposed && !art.isDestroy
+ const update = (value: ConnectionState) => {
+ option.onStateChange?.(value)
+ if (!active())
+ return
+ const button = element?.querySelector('.art-icon-cast')
+ if (button)
+ button.style.color = value === 'connected' ? 'red' : value === 'connecting' || value === 'disconnecting' ? 'orange' : 'white'
+ }
+ const onSession = (event: SessionEvent) => {
+ if (!active() || !sdk)
+ return
+ state = event.sessionState
+ const states = sdk.framework.SessionState
+ if (state === states.NO_SESSION || state === states.SESSION_ENDED || state === states.SESSION_START_FAILED) {
+ if (state !== states.NO_SESSION || operationStarted) {
+ generation++
+ cancelOperation?.()
+ }
+ session = null
+ update('disconnected')
+ }
+ else {
+ if (state === states.SESSION_ENDING || (session && event.session && event.session !== session)) {
+ generation++
+ cancelOperation?.()
+ }
+ session = event.session || null
+ if (state === states.SESSION_STARTING)
+ update('connecting')
+ else if (state === states.SESSION_ENDING)
+ update('disconnecting')
+ else if (state === states.SESSION_STARTED || state === states.SESSION_RESUMED)
+ update('connected')
+ }
+ }
+ const onAvailability = (event: AvailabilityEvent) => {
+ if (!active() || !sdk)
+ return
+ const states = sdk.framework.CastState
+ if (event.castState === states.NO_DEVICES_AVAILABLE)
+ option.onCastAvailable?.(false)
+ else if (event.castState === states.NOT_CONNECTED || event.castState === states.CONNECTING || event.castState === states.CONNECTED)
+ option.onCastAvailable?.(true)
+ }
+ const detach = () => {
+ const ownedContext = context
+ const ownedSdk = sdk
+ context = undefined
+ sdk = undefined
+ if (ownedContext && ownedSdk) {
+ const events = ownedSdk.framework.CastContextEventType
+ const errors: unknown[] = []
+ try {
+ ownedContext.removeEventListener(events.SESSION_STATE_CHANGED, onSession)
+ }
+ catch (error) {
+ errors.push(error)
+ }
+ try {
+ ownedContext.removeEventListener(events.CAST_STATE_CHANGED, onAvailability)
+ }
+ catch (error) {
+ errors.push(error)
+ }
+ if (errors.length)
+ throw errors[0]
+ }
+ }
+ const destroy = () => {
+ if (disposed)
+ return
+ disposed = true
+ cancel()
+ const release = releaseLoader
+ releaseLoader = undefined
+ const errors: unknown[] = []
+ for (const cleanup of [release, detach, () => art.off('destroy', destroy)]) {
+ try {
+ cleanup?.()
+ }
+ catch (error) {
+ errors.push(error)
+ }
+ }
+ session = null
+ // ArtPlayer owns removal of its controls at player destruction.
+ element = undefined
+ if (errors.length)
+ throw errors[0]
+ }
+ async function cast() {
+ const epoch = generation
+ const currentOperation = () => active() && generation === epoch
+ let stop!: () => void
+ const superseded = new Promise((resolve) => {
+ stop = () => resolve(undefined)
+ })
+ cancelOperation = stop
+ let failureNotice = 'Failed to initialize Cast API'
+ try {
+ if (!sdk || !context) {
+ const loading = loadSdk(option.sdk, (available) => {
+ if (!currentOperation())
+ return
+ sdk = available
+ context = configure(sdk)
+ if (!currentOperation())
+ return
+ const events = sdk.framework.CastContextEventType
+ try {
+ context.addEventListener(events.SESSION_STATE_CHANGED, onSession)
+ if (active())
+ context.addEventListener(events.CAST_STATE_CHANGED, onAvailability)
+ }
+ catch (error) {
+ try {
+ detach()
+ }
+ catch {
+ // Preserve the setup error; detach already tried both subscriptions.
+ }
+ throw error
+ }
+ })
+ releaseLoader = loading.release
+ await Promise.race([loading.promise, cancelled, superseded])
+ loading.release()
+ releaseLoader = undefined
+ }
+ if (!currentOperation() || !context || !sdk)
+ return
+ failureNotice = 'Error connecting to cast session'
+ operationStarted = true
+ let current = context.getCurrentSession()
+ if (!currentOperation())
+ return
+ if (!current) {
+ await Promise.race([context.requestSession(), cancelled, superseded])
+ if (!currentOperation())
+ return
+ current = context.getCurrentSession()
+ }
+ if (!currentOperation())
+ return
+ if (!current)
+ throw new Error('No active Cast session')
+ session = current
+ failureNotice = 'Error casting media'
+ await Promise.race([loadMedia(sdk, current, option, art.option.url), cancelled, superseded])
+ if (currentOperation()) {
+ art.notice.show = 'Casting started'
+ option.onCastStart?.()
+ }
+ }
+ catch (error) {
+ if (currentOperation()) {
+ art.notice.show = failureNotice
+ option.onError?.(error)
+ throw error
+ }
+ }
+ finally {
+ releaseLoader?.()
+ releaseLoader = undefined
+ if (cancelOperation === stop)
+ cancelOperation = undefined
+ operationStarted = false
+ }
+ }
+ const click = () => {
+ if (!active())
+ return Promise.resolve()
+ if (!activeClick) {
+ let resolve!: () => void
+ let reject!: (error: unknown) => void
+ activeClick = new Promise((yes, no) => {
+ resolve = yes
+ reject = no
+ })
+ // ArtPlayer's DOM dispatcher ignores click results. Observe rejection there
+ // while retaining the original rejecting Promise for programmatic callers.
+ activeClick.catch(() => {})
+ // Publish the guard before SDK setup can synchronously replay an event and
+ // reenter click through a user's onStateChange callback.
+ cast().then(() => {
+ activeClick = undefined
+ resolve()
+ }, (error) => {
+ activeClick = undefined
+ reject(error)
+ })
+ }
+ return activeClick
+ }
+ art.controls.add({
+ name: 'chromecast',
+ position: 'right',
+ tooltip: 'Chromecast',
+ html: `${option.icon || icon}`,
+ mounted(control) {
+ if (active())
+ element = control
+ },
+ click,
+ })
+ art.on('destroy', destroy)
+ if (art.isDestroy)
+ destroy()
+ return { name: 'artplayerPluginChromecast', getCastState: () => state, isCasting: () => session !== null }
+}
diff --git a/packages/artplayer-plugin-chromecast/src/index.js b/packages/artplayer-plugin-chromecast/src/index.js
deleted file mode 100644
index 96ba7b381..000000000
--- a/packages/artplayer-plugin-chromecast/src/index.js
+++ /dev/null
@@ -1,189 +0,0 @@
-function loadScript(src) {
- return new Promise((resolve, reject) => {
- const script = document.createElement('script')
- script.src = src
- script.onload = resolve
- script.onerror = reject
- document.body.appendChild(script)
- })
-}
-
-function getMimeType(url) {
- const extension = url.split('?')[0].split('#')[0].split('.').pop().toLowerCase()
- const mimeTypes = {
- mp4: 'video/mp4',
- webm: 'video/webm',
- ogg: 'video/ogg',
- ogv: 'video/ogg',
- mp3: 'audio/mp3',
- wav: 'audio/wav',
- flv: 'video/x-flv',
- mov: 'video/quicktime',
- avi: 'video/x-msvideo',
- wmv: 'video/x-ms-wmv',
- mpd: 'application/dash+xml',
- m3u8: 'application/x-mpegURL',
- }
- return mimeTypes[extension] || 'application/octet-stream'
-}
-
-export default function artplayerPluginChromecast(option) {
- const DEFAULT_ICON = ``
- const DEFAULT_SDK = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1'
-
- let isCastInitialized = false
- let castSession = null
- let castState = null
-
- const updateCastButton = (state) => {
- const button = document.querySelector('.art-icon-cast')
- if (button) {
- switch (state) {
- case 'connected':
- button.style.color = 'red'
- break
- case 'connecting':
- case 'disconnecting':
- button.style.color = 'orange'
- break
- case 'disconnected':
- default:
- button.style.color = 'white'
- break
- }
- }
- }
-
- const initializeCastApi = () => {
- return new Promise((resolve, reject) => {
- window.__onGCastApiAvailable = (isAvailable) => {
- if (isAvailable) {
- const context = window.cast.framework.CastContext.getInstance()
- context.setOptions({
- receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
- autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
- })
-
- // Listen for session state changes
- context.addEventListener(
- window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
- (event) => {
- const SessionState = window.cast.framework.SessionState
- castState = event.sessionState
- castSession = event.session
-
- switch (event.sessionState) {
- case SessionState.NO_SESSION:
- option.onStateChange?.('disconnected')
- updateCastButton('disconnected')
- break
- case SessionState.SESSION_STARTING:
- option.onStateChange?.('connecting')
- updateCastButton('connecting')
- break
- case SessionState.SESSION_STARTED:
- option.onStateChange?.('connected')
- updateCastButton('connected')
- break
- case SessionState.SESSION_ENDING:
- option.onStateChange?.('disconnecting')
- updateCastButton('disconnecting')
- break
- case SessionState.SESSION_RESUMED:
- option.onStateChange?.('connected')
- updateCastButton('connected')
- break
- }
- },
- )
-
- // Listen for cast state changes
- context.addEventListener(window.cast.framework.CastContextEventType.CAST_STATE_CHANGED, (event) => {
- const CastState = window.cast.framework.CastState
- switch (event.castState) {
- case CastState.NO_DEVICES_AVAILABLE:
- option.onCastAvailable?.(false)
- break
- case CastState.NOT_CONNECTED:
- option.onCastAvailable?.(true)
- break
- case CastState.CONNECTING:
- case CastState.CONNECTED:
- option.onCastAvailable?.(true)
- break
- }
- })
-
- isCastInitialized = true
- resolve()
- }
- else {
- reject(new Error('Cast API is not available'))
- }
- }
- if (!window.chrome || !window.chrome.cast) {
- loadScript(option.sdk || DEFAULT_SDK).catch(reject)
- }
- })
- }
-
- const castVideo = (art, session) => {
- const url = option.url || art.option.url
- const mediaInfo = new window.chrome.cast.media.MediaInfo(url, option.mimeType || getMimeType(url))
- const request = new window.chrome.cast.media.LoadRequest(mediaInfo)
- session
- .loadMedia(request)
- .then(() => {
- art.notice.show = 'Casting started'
- option.onCastStart?.()
- })
- .catch((error) => {
- art.notice.show = 'Error casting media'
- option.onError?.(error)
- throw error
- })
- }
-
- return async (art) => {
- art.controls.add({
- name: 'chromecast',
- position: 'right',
- tooltip: 'Chromecast',
- html: `${option.icon || DEFAULT_ICON}`,
- click: async () => {
- if (!isCastInitialized) {
- try {
- await initializeCastApi()
- }
- catch (error) {
- art.notice.show = 'Failed to initialize Cast API'
- option.onError?.(error)
- throw error
- }
- }
-
- const context = window.cast.framework.CastContext.getInstance()
- if (castSession) {
- castVideo(art, castSession)
- }
- else {
- try {
- const session = await context.requestSession()
- castVideo(art, session)
- }
- catch (error) {
- art.notice.show = 'Error connecting to cast session'
- option.onError?.(error)
- throw error
- }
- }
- },
- })
-
- return {
- name: 'artplayerPluginChromecast',
- getCastState: () => castState,
- isCasting: () => castSession !== null,
- }
- }
-}
diff --git a/packages/artplayer-plugin-chromecast/src/index.ts b/packages/artplayer-plugin-chromecast/src/index.ts
new file mode 100644
index 000000000..08733ad0a
--- /dev/null
+++ b/packages/artplayer-plugin-chromecast/src/index.ts
@@ -0,0 +1,10 @@
+import type Artplayer from 'artplayer'
+import type { ChromecastOptions } from './types'
+import { createController } from './controller'
+
+export default function artplayerPluginChromecast(option: ChromecastOptions) {
+ return async (art: Artplayer) => createController(art, option)
+}
+
+// The 1.0 CommonJS namespace exposed .default; the current root exports a function.
+Object.defineProperty(artplayerPluginChromecast, 'default', { value: artplayerPluginChromecast, writable: true, configurable: true })
diff --git a/packages/artplayer-plugin-chromecast/src/media.ts b/packages/artplayer-plugin-chromecast/src/media.ts
new file mode 100644
index 000000000..8150c9637
--- /dev/null
+++ b/packages/artplayer-plugin-chromecast/src/media.ts
@@ -0,0 +1,23 @@
+import type { CastSdk, CastSession, ChromecastOptions } from './types'
+
+const mimeTypes: Record = {
+ mp4: 'video/mp4',
+ webm: 'video/webm',
+ ogg: 'video/ogg',
+ ogv: 'video/ogg',
+ mp3: 'audio/mp3',
+ wav: 'audio/wav',
+ flv: 'video/x-flv',
+ mov: 'video/quicktime',
+ avi: 'video/x-msvideo',
+ wmv: 'video/x-ms-wmv',
+ mpd: 'application/dash+xml',
+ m3u8: 'application/x-mpegURL',
+}
+
+export function loadMedia(sdk: CastSdk, session: CastSession, option: ChromecastOptions, currentUrl: string): PromiseLike {
+ const url = option.url || currentUrl
+ const extension = url.split('?')[0]!.split('#')[0]!.split('.').pop()!.toLowerCase()
+ const info = new sdk.media.MediaInfo(url, option.mimeType || mimeTypes[extension] || 'application/octet-stream')
+ return session.loadMedia(new sdk.media.LoadRequest(info))
+}
diff --git a/packages/artplayer-plugin-chromecast/src/sdk.ts b/packages/artplayer-plugin-chromecast/src/sdk.ts
new file mode 100644
index 000000000..77f73e2c4
--- /dev/null
+++ b/packages/artplayer-plugin-chromecast/src/sdk.ts
@@ -0,0 +1,130 @@
+import type { CastContext, CastSdk, CastWindow } from './types'
+
+const DEFAULT_SDK = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1'
+interface Subscriber { ready: (sdk: CastSdk) => void, resolve: (sdk: CastSdk) => void, reject: (error: unknown) => void }
+interface Loading { subscribers: Set, cancel: () => void }
+const pending = new WeakMap()
+const configured = new WeakSet()
+
+function ready(host: CastWindow): CastSdk | undefined {
+ const framework = host.cast?.framework
+ const api = host.chrome?.cast
+ if (typeof framework?.CastContext?.getInstance === 'function'
+ && framework.CastContextEventType && framework.SessionState && framework.CastState
+ && typeof api?.media?.MediaInfo === 'function' && typeof api.media.LoadRequest === 'function'
+ && api.AutoJoinPolicy) { return { framework, media: api.media, autoJoinPolicy: api.AutoJoinPolicy.ORIGIN_SCOPED } }
+}
+
+export function configure(sdk: CastSdk): CastContext {
+ const context = sdk.framework.CastContext.getInstance()
+ if (!configured.has(context)) {
+ context.setOptions({ receiverApplicationId: sdk.media.DEFAULT_MEDIA_RECEIVER_APP_ID, autoJoinPolicy: sdk.autoJoinPolicy })
+ configured.add(context)
+ }
+ return context
+}
+
+function lease(loading: Loading, onReady: (sdk: CastSdk) => void) {
+ let subscriber!: Subscriber
+ const promise = new Promise((resolve, reject) => {
+ subscriber = { ready: onReady, resolve, reject }
+ loading.subscribers.add(subscriber)
+ })
+ let released = false
+ return { promise, release: () => {
+ if (released)
+ return
+ released = true
+ loading.subscribers.delete(subscriber)
+ subscriber.reject(new Error('Cast initialization cancelled'))
+ if (!loading.subscribers.size)
+ loading.cancel()
+ } }
+}
+
+export function loadSdk(src: string | undefined, onReady: (sdk: CastSdk) => void) {
+ const host = window as CastWindow
+ const loaded = ready(host)
+ if (loaded) {
+ const promise = new Promise((resolve, reject) => {
+ try {
+ onReady(loaded)
+ resolve(loaded)
+ }
+ catch (error) {
+ reject(error)
+ }
+ })
+ return { promise, release: () => {} }
+ }
+ const existing = pending.get(host)
+ if (existing)
+ return lease(existing, onReady)
+
+ const loading: Loading = { subscribers: new Set(), cancel: () => finish(undefined, new Error('Cast initialization cancelled')) }
+ pending.set(host, loading)
+ const subscription = lease(loading, onReady)
+ const previous = host.__onGCastApiAvailable
+ let script: HTMLScriptElement | undefined
+ let timer: number | undefined
+ let settled = false
+ function finish(sdk?: CastSdk, error?: unknown) {
+ if (settled)
+ return
+ settled = true
+ if (timer !== undefined)
+ host.clearTimeout(timer)
+ pending.delete(host)
+ if (host.__onGCastApiAvailable === available)
+ host.__onGCastApiAvailable = previous
+ if (script) {
+ script.onload = null
+ script.onerror = null
+ if (!sdk)
+ script.remove()
+ }
+ for (const subscriber of loading.subscribers) {
+ try {
+ if (sdk) {
+ subscriber.ready(sdk)
+ subscriber.resolve(sdk)
+ }
+ else {
+ subscriber.reject(error)
+ }
+ }
+ catch (error) {
+ subscriber.reject(error)
+ }
+ }
+ loading.subscribers.clear()
+ }
+ function available(value: boolean, errorInfo?: string) {
+ // The page's existing consumer keeps its callback and its exception semantics.
+ // Always settle our own waiters even if that consumer throws.
+ try {
+ previous?.call(host, value, errorInfo)
+ }
+ finally {
+ const sdk = value ? ready(host) : undefined
+ finish(sdk, new Error('Cast API is not available'))
+ }
+ }
+ host.__onGCastApiAvailable = available
+ try {
+ timer = host.setTimeout(() => finish(undefined, new Error('Timed out initializing Cast API')), 30000)
+ script = document.createElement('script')
+ script.src = src || DEFAULT_SDK
+ script.onerror = error => finish(undefined, error)
+ script.onload = () => {
+ const sdk = ready(host)
+ if (sdk)
+ finish(sdk)
+ }
+ document.body.appendChild(script)
+ }
+ catch (error) {
+ finish(undefined, error)
+ }
+ return subscription
+}
diff --git a/packages/artplayer-plugin-chromecast/src/types.ts b/packages/artplayer-plugin-chromecast/src/types.ts
new file mode 100644
index 000000000..13350f469
--- /dev/null
+++ b/packages/artplayer-plugin-chromecast/src/types.ts
@@ -0,0 +1,51 @@
+export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'disconnecting'
+
+export interface ChromecastOptions {
+ url?: string
+ sdk?: string
+ icon?: string
+ mimeType?: string
+ onStateChange?: (this: ChromecastOptions, state: ConnectionState) => void
+ onCastAvailable?: (this: ChromecastOptions, available: boolean) => void
+ onCastStart?: (this: ChromecastOptions) => void
+ onError?: (this: ChromecastOptions, error: unknown) => void
+}
+
+export interface MediaInfo {
+ contentId: string
+ contentType: string
+}
+
+export interface LoadRequest { media: MediaInfo }
+export interface CastSession { loadMedia: (request: LoadRequest) => PromiseLike }
+export interface SessionEvent { sessionState: string, session?: CastSession | null }
+export interface AvailabilityEvent { castState: string }
+
+export interface CastContext {
+ setOptions: (options: { receiverApplicationId: string, autoJoinPolicy: string }) => void
+ getCurrentSession: () => CastSession | null
+ requestSession: () => PromiseLike
+ addEventListener: (name: string, listener: (event: SessionEvent & AvailabilityEvent) => void) => void
+ removeEventListener: (name: string, listener: (event: SessionEvent & AvailabilityEvent) => void) => void
+}
+
+export interface CastSdk {
+ framework: {
+ CastContext: { getInstance: () => CastContext }
+ CastContextEventType: { SESSION_STATE_CHANGED: string, CAST_STATE_CHANGED: string }
+ SessionState: Record
+ CastState: Record
+ }
+ media: {
+ DEFAULT_MEDIA_RECEIVER_APP_ID: string
+ MediaInfo: new (url: string, mimeType: string) => MediaInfo
+ LoadRequest: new (media: MediaInfo) => LoadRequest
+ }
+ autoJoinPolicy: string
+}
+
+export interface CastWindow extends Window {
+ cast?: { framework?: CastSdk['framework'] }
+ chrome?: { cast?: { media?: CastSdk['media'], AutoJoinPolicy?: { ORIGIN_SCOPED: string } } }
+ __onGCastApiAvailable?: (available: boolean, errorInfo?: string) => void
+}
diff --git a/packages/artplayer-plugin-chromecast/tsconfig.json b/packages/artplayer-plugin-chromecast/tsconfig.json
new file mode 100644
index 000000000..1feac2a9c
--- /dev/null
+++ b/packages/artplayer-plugin-chromecast/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "allowJs": false },
+ "include": ["src/**/*.ts", "../../types/assets.d.ts"]
+}
diff --git a/refactor/baselines/chromecast-runtime-validation.json b/refactor/baselines/chromecast-runtime-validation.json
new file mode 100644
index 000000000..2c13eb251
--- /dev/null
+++ b/refactor/baselines/chromecast-runtime-validation.json
@@ -0,0 +1,185 @@
+{
+ "schemaVersion": 1,
+ "task": "PKG-CAST-03",
+ "status": "done",
+ "capturedAt": "2026-09-13T09:12:41.513Z",
+ "frozenSourceCommit": "146b78d5767bba88612184869155a39317794537",
+ "unit": [
+ {
+ "file": "refactor/.cache/chromecast03-combined.log",
+ "tests": 128,
+ "sha256": "9ee410f58d5a4f00b574ab8fabab6237e8befcbee7701ee519f2a029d9709eb0",
+ "failed": 0,
+ "skipped": 0
+ },
+ {
+ "file": "refactor/.cache/chromecast03-main.log",
+ "tests": 35,
+ "sha256": "31ae3664139c37f89e398364f5ec52d260fa27d6d9fad6a3d15540b23a3be739",
+ "failed": 0,
+ "skipped": 0
+ },
+ {
+ "file": "refactor/.cache/chromecast03-legacy.log",
+ "tests": 35,
+ "sha256": "f1488bc634c48d744b32f854686fc7a0ecdf2ccfd638c0ce3f9837bcbc5bdcbf",
+ "failed": 0,
+ "skipped": 0
+ }
+ ],
+ "browsers": [
+ {
+ "name": "source-final",
+ "file": "refactor/.cache/chromecast03-browser-source-final/report.json",
+ "sha256": "d59ba138015444168c11f728831ca9369c096282f62323472edce98f75f728d6",
+ "tests": 45,
+ "failures": 0,
+ "skips": 0,
+ "versions": [
+ "chromium: 153.0.8010.12",
+ "firefox: 155.0",
+ "webkit: 26.6"
+ ],
+ "inputSha256": "d973945154aa56dafa4ef0de5425661606cef25f1d363c05120f7764b9e53a94",
+ "sdk": "Controlled SDK; native DOM, actual cores and local video; no real receiver validation."
+ },
+ {
+ "name": "main",
+ "file": "refactor/.cache/chromecast03-browser-main/report.json",
+ "sha256": "23dca701c479232d4fc265a2b7fad657fbfdbded8892be5ff1bc5e34b4b7e963",
+ "tests": 45,
+ "failures": 0,
+ "skips": 0,
+ "versions": [
+ "chromium: 153.0.8010.12",
+ "firefox: 155.0",
+ "webkit: 26.6"
+ ],
+ "inputSha256": "80b12b3081086488c9d80f03979c336d4a6fdb44dca298e218dfeaaadb295d00",
+ "sdk": "Controlled SDK; native DOM, actual cores and local video; no real receiver validation."
+ },
+ {
+ "name": "legacy",
+ "file": "refactor/.cache/chromecast03-browser-legacy/report.json",
+ "sha256": "eebeb37f9e83d9a583d630c81a3685cc0a9be3c99c36f71ea02a9808fcc137b3",
+ "tests": 45,
+ "failures": 0,
+ "skips": 0,
+ "versions": [
+ "chromium: 153.0.8010.12",
+ "firefox: 155.0",
+ "webkit: 26.6"
+ ],
+ "inputSha256": "b21a30852403914a3bf3defc89aff11f2cc23b044ed68ccf667164b477f04172",
+ "sdk": "Controlled SDK; native DOM, actual cores and local video; no real receiver validation."
+ }
+ ],
+ "artifacts": [
+ {
+ "file": "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.js",
+ "copied": "docs/compiled/artplayer-plugin-chromecast.js",
+ "sha256": "80b12b3081086488c9d80f03979c336d4a6fdb44dca298e218dfeaaadb295d00",
+ "bytes": 6096,
+ "previousIconRemoved": true
+ },
+ {
+ "file": "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.legacy.js",
+ "copied": "docs/compiled/artplayer-plugin-chromecast.legacy.js",
+ "sha256": "b21a30852403914a3bf3defc89aff11f2cc23b044ed68ccf667164b477f04172",
+ "bytes": 6633,
+ "previousIconRemoved": true
+ },
+ {
+ "file": "packages/artplayer-plugin-chromecast/dist/artplayer-plugin-chromecast.mjs",
+ "copied": "docs/compiled/artplayer-plugin-chromecast.mjs",
+ "sha256": "68d81fcab469f74fe9d9740842d989e10144f7ff28c7ade91ed22adee90f44d7",
+ "bytes": 11546,
+ "previousIconRemoved": true
+ }
+ ],
+ "inputs": [
+ {
+ "path": "packages/artplayer-plugin-chromecast/src/cast.svg",
+ "algorithm": "sha256-lf",
+ "sha256": "48470d66b5874569c54d66889b0ec62c33bf809670d4baf2eded2bfa83d6d726"
+ },
+ {
+ "path": "packages/artplayer-plugin-chromecast/src/controller.ts",
+ "algorithm": "sha256-lf",
+ "sha256": "e61760f082e233da1f4e94ca98c68b4f07357117a66b2b5c448659dd57cb193d"
+ },
+ {
+ "path": "packages/artplayer-plugin-chromecast/src/index.ts",
+ "algorithm": "sha256-lf",
+ "sha256": "951d8bbaf273c56722d3767a514a3f8a65050122d934612e5c53c8349d1400e7"
+ },
+ {
+ "path": "packages/artplayer-plugin-chromecast/src/media.ts",
+ "algorithm": "sha256-lf",
+ "sha256": "6c16b3cc7b5c570dbb6ae7a4ff9c1492f8e6a07d07662db2b417ff825b7f0dd1"
+ },
+ {
+ "path": "packages/artplayer-plugin-chromecast/src/sdk.ts",
+ "algorithm": "sha256-lf",
+ "sha256": "eee5ff443406c400bd54822b71b20e1bb8a9191e15c625eb329cb3207a01902f"
+ },
+ {
+ "path": "packages/artplayer-plugin-chromecast/src/types.ts",
+ "algorithm": "sha256-lf",
+ "sha256": "9958a32fb20e0ed55b7f0b6d34e02febe67966a972a9055999f5a030d034a178"
+ },
+ {
+ "path": "packages/artplayer-plugin-chromecast/tsconfig.json",
+ "algorithm": "sha256-lf",
+ "sha256": "4e4d4e725a1f49736504f408a1b56abb6a1f1aec9213be890b5db6c7c7a28002"
+ },
+ {
+ "path": "test/browser/chromecast-lifecycle.spec.js",
+ "algorithm": "sha256-lf",
+ "sha256": "8a9b47f899e018cb43d396d58ffc29144c97f5feef753eb9b91df0cf8ecd674e"
+ },
+ {
+ "path": "test/chromecast-runtime.test.js",
+ "algorithm": "sha256-lf",
+ "sha256": "94ce42f8d92f529a7ca93294369f017e6953405a4ea84b3bfbd0bc50f178c67a"
+ }
+ ],
+ "priorFailure": {
+ "file": "refactor/.cache/chromecast03-browser-initial/report.json",
+ "sha256": "fa189b92b0d6b0a0812fdb62bc75fa95e0fce14aabfa02d4323e1d5c7cacba37",
+ "expected": 18,
+ "unexpected": 27,
+ "cause": "Production assumed Control.add returns a node; WebKit test misclassified same-origin Blob URLs. Both corrected without deleting assertions."
+ },
+ "focusedRedGreen": [
+ {
+ "file": "refactor/.cache/chromecast03-mounted-red.log",
+ "sha256": "fbafc237ac36de524f918310a4aa682106c0fe40604f601b918257b58aa6de72"
+ },
+ {
+ "file": "refactor/.cache/chromecast03-mounted-green.log",
+ "sha256": "9e3452207925efed6e2b7e84d7facd51bad61d1d3f10384e41883de2bdba3ef5"
+ }
+ ],
+ "checks": [
+ {
+ "file": "refactor/.cache/chromecast03-build.log",
+ "sha256": "538ecc3cd0d78f3b25f25ba3e3263f7f78988ea9126526d959cb42341bc7a495"
+ },
+ {
+ "file": "refactor/.cache/chromecast03-typecheck.log",
+ "sha256": "881cbc6371263597ec45d5af816997f5d93bdda99045a532df8a04a9cbec7c07"
+ },
+ {
+ "file": "refactor/.cache/chromecast03-lint.log",
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ }
+ ],
+ "limitations": [
+ "Public declarations still owned by CAST04.",
+ "SDK06/CAST05 real HTTPS sender, Cast SDK, receiver and network playback remain unverified.",
+ "Controlled Windows WebKit is not Safari device evidence.",
+ "Pending loader ownership is shared within one loaded plugin module, not independently loaded copies."
+ ],
+ "releaseReady": false
+}
diff --git a/refactor/changes/2026-09-13-PKG-CAST-03-runtime.md b/refactor/changes/2026-09-13-PKG-CAST-03-runtime.md
new file mode 100644
index 000000000..d092ead6a
--- /dev/null
+++ b/refactor/changes/2026-09-13-PKG-CAST-03-runtime.md
@@ -0,0 +1,50 @@
+# PKG-CAST-03 Chromecast 模块与生命周期修复
+
+原单文件拆为5个strict TypeScript模块:入口、SDK加载协调、每实例控制器、媒体请求、
+内部SDK类型。公开npm声明保持原状,精确公开类型留给04;不会因内部TS迁移静默
+修改历史类型。保留异步注册、控件name/位置/tooltip/HTML hooks、live选项与回调this、
+原始SessionState getter和旧CommonJS default调用。详细维护边界见
+[包内架构](../../packages/artplayer-plugin-chromecast/ARCHITECTURE.md)。
+
+## 已修复行为
+
+- requestSession成功后重新读取getCurrentSession,不再把完成值当session。
+- 完整SDK立即复用,同模块多实例共享待加载脚本;保留外部availability回调,失败
+ 可重试,最后等待者销毁可释放自有脚本。无SDK就绪信号时30秒超时,定时器可释放。
+- availability同步安装监听,初始NO_SESSION不吞掉首次请求;请求/媒体期间终态
+ 和会话替换取消过时结果。重复及同步重入点击共享同一个操作。
+- await完整loadMedia链,原始错误只通知一次;DOM点击无人接收Promise时有独立
+ 拒绝观察,程序调用仍收到原始拒绝。销毁后没有晚到成功提示或新媒体加载。
+- 每实例分别维护状态与监听,销毁尝试全部清理,不结束页面共享投屏会话。
+- 默认图标换为本项目原创几何SVG,保留自定义icon与外层class。旧商业图形仅保留
+ 在历史基线,候选源码、三格式dist及docs/compiled均移除该path。
+
+## 真实浏览器反查出的错误
+
+首轮源码浏览器45项中18通过、27失败,原始报告和trace保存在
+`refactor/.cache/chromecast03-browser-initial/`,没有删除失败证据。
+其中实际Control.add返回undefined,测试替身却返回节点,掩盖了候选图标不变色。
+修复改用既有mounted回调捕获本控件,候选替身也改为准确回放mounted并返回undefined。
+改测试后、改源码前的红例保留在`chromecast03-mounted-red.log`;修复后35项绿例在
+`chromecast03-mounted-green.log`。没有改核心返回值,也没有弱化颜色断言。
+
+WebKit另有测试边界错误:同源Blob URL被误当外网。测试仅放行与测试baseURL精确
+同origin的blob,真正外网仍阻断并断言零尝试;没有拦截unhandledrejection来掩盖错误。
+
+## 验证与未完成范围
+
+93项冻结历史行为/故障测试和35项候选测试共128通过;最终main、legacy各35项通过,
+并核对真实mjs及可写default身份。正常Yarn构建更新三格式dist与docs/compiled。
+共享strict检查覆盖366个生产TS文件;定向lint通过。
+
+源码、最终main、最终legacy各45项真实浏览器测试通过,共135项、零skip。
+每组使用真实核心5.3.0、最新已冻结发布核心及候选核心,运行Chromium/Firefox/Windows
+WebKit;断言实际DOM点击、控件归属、状态颜色、加载/销毁、失败重试与无未处理错误。
+SDK明确为受控替身,本地视频和控件是真实浏览器对象。Windows WebKit不等于Safari
+设备支持,以上不证明真实SDK发现设备或接收器播放。
+
+本任务关闭5项已复现Cast实现风险及候选图标来源风险;SDK-06真实HTTPS sender、
+接收设备与媒体可达性留给05,公开类型留给04,完整npm分发验收留给06。不同模块副本
+不共享内部加载注册表;页面其他Cast应用的接收器配置不由本插件协商。所有这些限制
+已写入包内文档,没有静默判作通过。可追溯哈希、日志和范围见
+[验证记录](../baselines/chromecast-runtime-validation.json)。独立本地完成提交,不推送或发布。
diff --git a/refactor/plan.md b/refactor/plan.md
index d402828cd..52545d3c3 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 228 项,范围 22 个包及工作区/示例。
-状态:todo 78 / doing 15 / blocked 0 / done 135 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 78 / doing 14 / blocked 0 / done 136 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -289,7 +289,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| PKG-CAST-01 | artplayer-plugin-chromecast
核对包契约与历史用法 | BASE-05 | url/sdk/icon/mimeType、loader、会话、全局 SDK 与结果 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done |
| PKG-CAST-02 | artplayer-plugin-chromecast
建立特有行为与错误测试 | PKG-CAST-01, ENG-03, ENG-05 | SDK 脚本加载失败/重入、无设备/拒绝、重复实例与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
-| PKG-CAST-03 | artplayer-plugin-chromecast
整理内部职责与资源 | PKG-CAST-02, CORE-11, CORE-13 | SDK singleton/实例订阅/会话与控件职责分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | doing |
+| PKG-CAST-03 | artplayer-plugin-chromecast
整理内部职责与资源 | PKG-CAST-02, CORE-11, CORE-13 | SDK singleton/实例订阅/会话与控件职责分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
| PKG-CAST-04 | artplayer-plugin-chromecast
迁移自有源码和公开类型 | PKG-CAST-03, ENG-04, ENG-06, CORE-07 | Cast 能力 adapter、全局对象和元数据类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-CAST-05 | artplayer-plugin-chromecast
验证新旧核心和组合 | PKG-CAST-04, CORE-22 | stub 测试之外记录实际 Cast 设备会话、源更新和断开 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-CAST-06 | artplayer-plugin-chromecast
验证分发并同步文档 | PKG-CAST-05, ENG-07 | chromecast.js、SDK URL/权限能力及待支持环境说明 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
@@ -544,6 +544,7 @@
- PKG-VAST-03: [记录](changes/2026-09-12-PKG-VAST-03-lifecycle.md) [记录](baselines/vast-lifecycle-validation.json) [记录](vast-compatibility-decision.md)
- PKG-CAST-01: [记录](baselines/chromecast-release.json) [记录](baselines/chromecast-contract.md) [记录](baselines/chromecast-contract-validation.json) [记录](changes/2026-09-13-PKG-CAST-01-contracts.md)
- PKG-CAST-02: [记录](changes/2026-09-13-PKG-CAST-02-failures.md) [记录](baselines/chromecast-failures-validation.json)
+- PKG-CAST-03: [记录](changes/2026-09-13-PKG-CAST-03-runtime.md) [记录](baselines/chromecast-runtime-validation.json)
- PKG-DPIP-01: [记录](changes/2026-09-12-PKG-DPIP-01-contract.md) [记录](baselines/dpip-release.json) [记录](baselines/dpip-contract.md) [记录](baselines/dpip-contract-validation.json) [记录](dpip-validation.md)
- PKG-DPIP-02: [记录](changes/2026-09-12-PKG-DPIP-02-tests.md) [记录](baselines/dpip-behavior-validation.json) [记录](dpip-validation.md)
- PKG-DPIP-03: [记录](changes/2026-09-12-PKG-DPIP-03-lifecycle.md) [记录](baselines/dpip-lifecycle-validation.json) [记录](dpip-validation.md)
diff --git a/refactor/progress.md b/refactor/progress.md
index 9b8f437dd..e8aebcb22 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,16 @@
# 进度与证据
+## PKG-CAST-03 Chromecast生产重构完成
+
+5个TS模块与35个候选测试落地,SDK等待/会话返回值/过期结果/实例图标/错误Promise
+已修复。真实浏览器进一步发现Control.add返回undefined导致图标不变色,已用mounted
+修复并保留红绿证据。128项历史+候选测试、main/legacy各35项、三形态各45项浏览器
+全部通过;正常构建和366生产TS strict检查通过。原商业SVG换为原创几何图形。
+见[变更](changes/2026-09-13-PKG-CAST-03-runtime.md)及[证据](baselines/chromecast-runtime-validation.json)。
+228项:136 done、14 doing、78 todo。04公开类型、05真实SDK/设备、06分发继续;
+135浏览器项使用受控SDK,不能当作接收设备投屏通过。没有推送或发布。
+
+
## PKG-MULTI-SUB-04 Multiple Subtitles 类型兼容实施完成
已落实用户批准的最新npm 1.2.0根类型及较早1.0.0/1.1.0迁移规则;/runtime准确
diff --git a/refactor/risk-table.md b/refactor/risk-table.md
index a5bdde42a..f1cf6497a 100644
--- a/refactor/risk-table.md
+++ b/refactor/risk-table.md
@@ -214,10 +214,10 @@
| ASR-FOREIGN-01 | resolved / 已复现 | Firefox permits parallel native media-element owners instead of selecting capture fallback | PKG-ASR-05, PKG-ASR-09 |
| ASR-CORS-01 | open / 已复现 | Default WebAudio binding silences CORS-cross-origin media that otherwise advances normally | PKG-ASR-05 |
| ASR-RESTART-01 | resolved / 已复现 | Delayed captured-source restart resumes recognition after a newer pause or stop | PKG-ASR-09 |
-| VENDOR-10 | open / 源码/产物事实 | Chromecast embeds a Font Awesome Pro path with no established redistribution entitlement | PKG-CAST-03, PKG-CAST-06 |
+| VENDOR-10 | resolved / 源码/产物事实 | Chromecast embeds a Font Awesome Pro path with no established redistribution entitlement | PKG-CAST-03, PKG-CAST-06 |
| CAST-TYPE-01 | open / 源码/产物事实 | Chromecast declarations describe a synchronous name-only registration and omit existing runtime callbacks and query methods | PKG-CAST-04 |
-| CAST-SDK-01 | open / 已复现 | Cast requestSession completion is incorrectly passed to loadMedia as a session | PKG-CAST-03, PKG-CAST-05 |
-| CAST-LOADER-01 | open / 已复现 | Cast readiness waits hang or overwrite each other across ready SDKs, repeated clicks and multiple instances | PKG-CAST-03, PKG-CAST-05 |
-| CAST-LIFE-01 | open / 已复现 | Cast listeners and continuations survive player destruction and update another player icon | PKG-CAST-03, PKG-CAST-05 |
-| CAST-STATE-01 | open / 已复现 | Cast terminal session states leave the normalized callback and icon connected | PKG-CAST-03, PKG-CAST-05 |
-| CAST-ERROR-01 | open / 已复现 | Cast media loading rejection escapes on a detached Promise while the click has already fulfilled | PKG-CAST-03, PKG-CAST-05 |
+| CAST-SDK-01 | resolved / 已复现 | Cast requestSession completion is incorrectly passed to loadMedia as a session | PKG-CAST-03, PKG-CAST-05 |
+| CAST-LOADER-01 | resolved / 已复现 | Cast readiness waits hang or overwrite each other across ready SDKs, repeated clicks and multiple instances | PKG-CAST-03, PKG-CAST-05 |
+| CAST-LIFE-01 | resolved / 已复现 | Cast listeners and continuations survive player destruction and update another player icon | PKG-CAST-03, PKG-CAST-05 |
+| CAST-STATE-01 | resolved / 已复现 | Cast terminal session states leave the normalized callback and icon connected | PKG-CAST-03, PKG-CAST-05 |
+| CAST-ERROR-01 | resolved / 已复现 | Cast media loading rejection escapes on a detached Promise while the click has already fulfilled | PKG-CAST-03, PKG-CAST-05 |
diff --git a/refactor/risks.json b/refactor/risks.json
index 4c6861f23..02b18e6cc 100644
--- a/refactor/risks.json
+++ b/refactor/risks.json
@@ -713,7 +713,7 @@
"PKG-CAST-05"
],
"evidence": [
- "packages/artplayer-plugin-chromecast/src/index.js"
+ "packages/artplayer-plugin-chromecast/src/sdk.ts"
],
"compatibleResolution": "Default remote cast_sender.js?loadCastFramework=1; sdk URL/options and actual Cast device/session behavior require separate validation.",
"closureCriteria": "固定 SDK/外部资源版本和环境;成功/失败/切换/销毁有真实证据,设备和网络限制逐项记录。"
@@ -4743,7 +4743,7 @@
"id": "VENDOR-10",
"title": "Chromecast embeds a Font Awesome Pro path with no established redistribution entitlement",
"confirmation": "source-observed",
- "status": "open",
+ "status": "resolved",
"owners": [
"PKG-CAST-03",
"PKG-CAST-06"
@@ -4753,7 +4753,14 @@
"refactor/baselines/chromecast-release.json"
],
"compatibleResolution": "Replace the default embedded artwork with an owned simple Cast indicator while retaining the custom icon option and wrapper classes; keep historical provenance separate.",
- "closureCriteria": "Owned or properly licensed default artwork replaces the commercial-marked source and every shipped bundle, with source/provenance and generated-output checks."
+ "closureCriteria": "Owned or properly licensed default artwork replaces the commercial-marked source and every shipped bundle, with source/provenance and generated-output checks.",
+ "resolutionEvidence": [
+ "refactor/changes/2026-09-13-PKG-CAST-03-runtime.md",
+ "refactor/baselines/chromecast-runtime-validation.json",
+ "test/chromecast-runtime.test.js",
+ "test/browser/chromecast-lifecycle.spec.js"
+ ],
+ "resolutionRationale": "Original repo-authored geometric SVG replaces the commercial path in source and all three built/copied formats; historical source remains frozen, no claim of rights in the removed artwork."
},
{
"id": "CAST-TYPE-01",
@@ -4774,7 +4781,7 @@
"id": "CAST-SDK-01",
"title": "Cast requestSession completion is incorrectly passed to loadMedia as a session",
"confirmation": "reproduced",
- "status": "open",
+ "status": "resolved",
"owners": [
"PKG-CAST-03",
"PKG-CAST-05"
@@ -4784,13 +4791,20 @@
"refactor/baselines/chromecast-failures-validation.json"
],
"compatibleResolution": "Retrieve the actual current session after request success and validate live instance ownership.",
- "closureCriteria": "Successful undefined/null request result loads through the current session; absence and refusal preserve appropriate original errors."
+ "closureCriteria": "Successful undefined/null request result loads through the current session; absence and refusal preserve appropriate original errors.",
+ "resolutionEvidence": [
+ "refactor/changes/2026-09-13-PKG-CAST-03-runtime.md",
+ "refactor/baselines/chromecast-runtime-validation.json",
+ "test/chromecast-runtime.test.js",
+ "test/browser/chromecast-lifecycle.spec.js"
+ ],
+ "resolutionRationale": "35 candidate source/main/legacy tests and 135 native browser DOM/core cases verify corrected behavior against controlled SDK boundaries. 93 frozen historical observations remain. Actual remote SDK and receivers remain SDK06/CAST05, not waived."
},
{
"id": "CAST-LOADER-01",
"title": "Cast readiness waits hang or overwrite each other across ready SDKs, repeated clicks and multiple instances",
"confirmation": "reproduced",
- "status": "open",
+ "status": "resolved",
"owners": [
"PKG-CAST-03",
"PKG-CAST-05"
@@ -4800,13 +4814,20 @@
"refactor/baselines/chromecast-failures-validation.json"
],
"compatibleResolution": "Coordinate one reusable loader per document with readiness detection, shared pending work and complete error settlement.",
- "closureCriteria": "Ready, pending, failure, retry, external callback and multiple instances are covered without duplicate scripts or stranded callers."
+ "closureCriteria": "Ready, pending, failure, retry, external callback and multiple instances are covered without duplicate scripts or stranded callers.",
+ "resolutionEvidence": [
+ "refactor/changes/2026-09-13-PKG-CAST-03-runtime.md",
+ "refactor/baselines/chromecast-runtime-validation.json",
+ "test/chromecast-runtime.test.js",
+ "test/browser/chromecast-lifecycle.spec.js"
+ ],
+ "resolutionRationale": "35 candidate source/main/legacy tests and 135 native browser DOM/core cases verify corrected behavior against controlled SDK boundaries. 93 frozen historical observations remain. Actual remote SDK and receivers remain SDK06/CAST05, not waived."
},
{
"id": "CAST-LIFE-01",
"title": "Cast listeners and continuations survive player destruction and update another player icon",
"confirmation": "reproduced",
- "status": "open",
+ "status": "resolved",
"owners": [
"PKG-CAST-03",
"PKG-CAST-05"
@@ -4816,13 +4837,20 @@
"refactor/baselines/chromecast-failures-validation.json"
],
"compatibleResolution": "Use per-registration lifecycle ownership, scoped control nodes and obsolete-operation checks without ending a page-shared session.",
- "closureCriteria": "Destroyed players get no late media loads or callbacks; subscriptions are removed and separate players keep independent UI."
+ "closureCriteria": "Destroyed players get no late media loads or callbacks; subscriptions are removed and separate players keep independent UI.",
+ "resolutionEvidence": [
+ "refactor/changes/2026-09-13-PKG-CAST-03-runtime.md",
+ "refactor/baselines/chromecast-runtime-validation.json",
+ "test/chromecast-runtime.test.js",
+ "test/browser/chromecast-lifecycle.spec.js"
+ ],
+ "resolutionRationale": "35 candidate source/main/legacy tests and 135 native browser DOM/core cases verify corrected behavior against controlled SDK boundaries. 93 frozen historical observations remain. Actual remote SDK and receivers remain SDK06/CAST05, not waived."
},
{
"id": "CAST-STATE-01",
"title": "Cast terminal session states leave the normalized callback and icon connected",
"confirmation": "reproduced",
- "status": "open",
+ "status": "resolved",
"owners": [
"PKG-CAST-03",
"PKG-CAST-05"
@@ -4832,13 +4860,20 @@
"refactor/baselines/chromecast-failures-validation.json"
],
"compatibleResolution": "Map terminal states without changing the raw SessionState query API.",
- "closureCriteria": "Ended and failed states update UI and callbacks while getCastState keeps original SessionState values."
+ "closureCriteria": "Ended and failed states update UI and callbacks while getCastState keeps original SessionState values.",
+ "resolutionEvidence": [
+ "refactor/changes/2026-09-13-PKG-CAST-03-runtime.md",
+ "refactor/baselines/chromecast-runtime-validation.json",
+ "test/chromecast-runtime.test.js",
+ "test/browser/chromecast-lifecycle.spec.js"
+ ],
+ "resolutionRationale": "35 candidate source/main/legacy tests and 135 native browser DOM/core cases verify corrected behavior against controlled SDK boundaries. 93 frozen historical observations remain. Actual remote SDK and receivers remain SDK06/CAST05, not waived."
},
{
"id": "CAST-ERROR-01",
"title": "Cast media loading rejection escapes on a detached Promise while the click has already fulfilled",
"confirmation": "reproduced",
- "status": "open",
+ "status": "resolved",
"owners": [
"PKG-CAST-03",
"PKG-CAST-05"
@@ -4848,7 +4883,14 @@
"refactor/baselines/chromecast-failures-validation.json"
],
"compatibleResolution": "Await the complete media-loading chain and preserve one original error notification.",
- "closureCriteria": "loadMedia failure reaches the click promise exactly once, no orphan rejection or duplicated error callback; success ordering remains documented."
+ "closureCriteria": "loadMedia failure reaches the click promise exactly once, no orphan rejection or duplicated error callback; success ordering remains documented.",
+ "resolutionEvidence": [
+ "refactor/changes/2026-09-13-PKG-CAST-03-runtime.md",
+ "refactor/baselines/chromecast-runtime-validation.json",
+ "test/chromecast-runtime.test.js",
+ "test/browser/chromecast-lifecycle.spec.js"
+ ],
+ "resolutionRationale": "35 candidate source/main/legacy tests and 135 native browser DOM/core cases verify corrected behavior against controlled SDK boundaries. 93 frozen historical observations remain. Actual remote SDK and receivers remain SDK06/CAST05, not waived."
}
]
}
diff --git a/refactor/tasks.json b/refactor/tasks.json
index 5dfb6b1e8..bfa402a91 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -3043,11 +3043,14 @@
"CORE-11",
"CORE-13"
],
- "status": "doing",
+ "status": "done",
"risk": "H",
"deliverable": "SDK singleton/实例订阅/会话与控件职责分离",
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
- "evidence": []
+ "evidence": [
+ "changes/2026-09-13-PKG-CAST-03-runtime.md",
+ "baselines/chromecast-runtime-validation.json"
+ ]
},
{
"id": "PKG-CAST-04",
diff --git a/refactor/third-party.json b/refactor/third-party.json
index ba697f7cf..621212044 100644
--- a/refactor/third-party.json
+++ b/refactor/third-party.json
@@ -1078,30 +1078,32 @@
"riskId": "VENDOR-10",
"inputs": [
"packages/artplayer-plugin-chromecast/src/cast.svg",
- "packages/artplayer-plugin-chromecast/src/index.js"
+ "packages/artplayer-plugin-chromecast/src/controller.ts"
],
"owners": [
"PKG-CAST-03",
"PKG-CAST-06"
],
- "sourceStatus": "Exact SVG path matches the runtime default icon; source notice identifies Font Awesome Pro 6.5.1.",
- "licenseStatus": "Commercial License notice exists in cast.svg; repository entitlement and redistribution authorization are not established by the package MIT metadata.",
- "updatePolicy": "Replace with owned artwork or verify permission before release; preserve user-supplied icon support and icon wrapper classes, retain historical archive evidence.",
+ "sourceStatus": "Former commercial SVG removed. Current cast.svg is an original geometric icon authored for this repository during PKG-CAST-03; controller imports that file directly.",
+ "licenseStatus": "Current original repository artwork follows the package MIT license; no entitlement in or relicensing of the removed Font Awesome Pro artwork is asserted.",
+ "updatePolicy": "Keep original artwork and custom icon/wrapper support; review any replacement provenance and verify actual rebuilt/shipped assets. Historical commercial-source evidence remains immutable.",
"fingerprints": [
{
"path": "packages/artplayer-plugin-chromecast/src/cast.svg",
"algorithm": "sha256-lf",
- "sha256": "8c0f9ad82c2d6c70afb70ab5002204c4948dd282fbf52095aed4b07fa8d466d4"
+ "sha256": "48470d66b5874569c54d66889b0ec62c33bf809670d4baf2eded2bfa83d6d726"
},
{
- "path": "packages/artplayer-plugin-chromecast/src/index.js",
+ "path": "packages/artplayer-plugin-chromecast/src/controller.ts",
"algorithm": "sha256-lf",
- "sha256": "2ab8967cf7fdbf9fbac8a9afe73713e66047bad7e4d4afc802665ce594fa3274"
+ "sha256": "e61760f082e233da1f4e94ca98c68b4f07357117a66b2b5c448659dd57cb193d"
}
],
"reviewEvidence": [
"refactor/baselines/chromecast-release.json",
- "refactor/changes/2026-09-13-PKG-CAST-01-contracts.md"
+ "refactor/changes/2026-09-13-PKG-CAST-01-contracts.md",
+ "refactor/changes/2026-09-13-PKG-CAST-03-runtime.md",
+ "refactor/baselines/chromecast-runtime-validation.json"
]
}
],
@@ -1191,10 +1193,12 @@
"PKG-CAST-05"
],
"sourceFiles": [
- "packages/artplayer-plugin-chromecast/src/index.js"
+ "packages/artplayer-plugin-chromecast/src/sdk.ts",
+ "packages/artplayer-plugin-chromecast/src/controller.ts",
+ "packages/artplayer-plugin-chromecast/src/media.ts"
],
"contract": "Default remote cast_sender.js?loadCastFramework=1; sdk URL/options and actual Cast device/session behavior require separate validation.",
- "validationStatus": "Real 1.0.0/1.1.0 archives and controlled public behavior frozen. Official requestSession completion is not a session; runtime/source gaps proceed through CAST-02/03. No actual SDK bytes, HTTPS sender, receiver, casting or media networking acceptance.",
+ "validationStatus": "Controlled SDK regression and native browser DOM/core tests verified source/main/legacy behavior in CAST03. No actual SDK bytes/version, HTTPS sender, physical receiver, discovery or receiver media networking acceptance; CAST05 remains outstanding.",
"demoPathsSource": "refactor/baselines/demo-inventory.json"
},
{
diff --git a/test/browser/chromecast-lifecycle.spec.js b/test/browser/chromecast-lifecycle.spec.js
new file mode 100644
index 000000000..462d5e941
--- /dev/null
+++ b/test/browser/chromecast-lifecycle.spec.js
@@ -0,0 +1,261 @@
+import fs from 'node:fs'
+import process from 'node:process'
+import { hash } from '../../refactor/scripts/releases.mjs'
+import { compilePackage } from '../helpers/load.js'
+import { expect, test } from './fixtures.js'
+
+let candidate
+test.beforeAll(async () => {
+ candidate = process.env.ARTPLAYER_CHROMECAST_ARTIFACT
+ ? fs.readFileSync(process.env.ARTPLAYER_CHROMECAST_ARTIFACT, 'utf8')
+ : await compilePackage('artplayer-plugin-chromecast', 'umd')
+})
+
+async function setup(page, core, testInfo, loaded = true) {
+ const external = []
+ await page.route('**/*', (route) => {
+ const url = new URL(route.request().url())
+ const local = ['127.0.0.1', 'localhost'].includes(url.hostname)
+ const localBlob = url.protocol === 'blob:' && url.origin === new URL(testInfo.project.use.baseURL).origin
+ if (!local && !localBlob) {
+ external.push(url.href)
+ return route.abort()
+ }
+ return route.continue()
+ })
+ await page.goto(`/test/player.html?core=${core}`)
+ await page.addScriptTag({ content: candidate })
+ await page.evaluate(async (loaded) => {
+ const listeners = new Map()
+ const stub = window.castStub = {
+ requests: [],
+ loads: [],
+ options: [],
+ callbacks: [[], []],
+ starts: [0, 0],
+ errors: [[], []],
+ current: null,
+ deferredRequest: false,
+ previousCalls: [],
+ emit(type, event) { for (const listener of [...(listeners.get(type) || [])]) listener(event) },
+ count() { return [...listeners.values()].reduce((sum, set) => sum + set.size, 0) },
+ sessionState(value) {
+ this.emit('session', { sessionState: value, session: this.current })
+ },
+ }
+ const session = {
+ loadMedia(request) {
+ return new Promise((resolve, reject) => stub.loads.push({ request, resolve, reject }))
+ },
+ }
+ const context = {
+ setOptions(option) { stub.options.push(option) },
+ getCurrentSession() { return stub.current },
+ addEventListener(type, listener) {
+ if (!listeners.has(type))
+ listeners.set(type, new Set())
+ listeners.get(type).add(listener)
+ },
+ removeEventListener(type, listener) { listeners.get(type)?.delete(listener) },
+ requestSession() {
+ return new Promise((resolve) => {
+ const accept = () => {
+ stub.current = session
+ stub.sessionState('SESSION_STARTED')
+ // The real SDK resolves without returning the CastSession.
+ resolve(undefined)
+ }
+ stub.requests.push({ accept })
+ if (!stub.deferredRequest)
+ accept()
+ })
+ },
+ }
+ window.installCastStub = () => {
+ window.cast = { framework: {
+ CastContext: { getInstance: () => context },
+ CastContextEventType: { SESSION_STATE_CHANGED: 'session', CAST_STATE_CHANGED: 'availability' },
+ SessionState: Object.fromEntries(['NO_SESSION', 'SESSION_STARTING', 'SESSION_STARTED', 'SESSION_RESUMED', 'SESSION_ENDING', 'SESSION_ENDED', 'SESSION_START_FAILED'].map(name => [name, name])),
+ CastState: Object.fromEntries(['NO_DEVICES_AVAILABLE', 'NOT_CONNECTED', 'CONNECTING', 'CONNECTED'].map(name => [name, name])),
+ } }
+ window.chrome ||= {}
+ window.chrome.cast = {
+ AutoJoinPolicy: { ORIGIN_SCOPED: 'origin' },
+ media: {
+ DEFAULT_MEDIA_RECEIVER_APP_ID: 'controlled-receiver',
+ MediaInfo: class { constructor(contentId, contentType) { Object.assign(this, { contentId, contentType }) } },
+ LoadRequest: class { constructor(media) { this.media = media } },
+ },
+ }
+ }
+ if (loaded)
+ window.installCastStub()
+ window.previousCastCallback = (...args) => stub.previousCalls.push(args)
+ window.__onGCastApiAvailable = window.previousCastCallback
+ window.castPlayers = []
+ window.registrationBefore = []
+ for (let index = 0; index < 2; index++) {
+ const container = index === 0 ? document.querySelector('.player') : document.body.appendChild(document.createElement('div'))
+ container.id = `cast-player-${index}`
+ container.style.cssText = 'width:480px;height:270px'
+ const art = new window.Artplayer({ container, url: `/test/pattern.mp4?player=${index}`, muted: true, mutex: false })
+ window.castPlayers.push(art)
+ const registration = art.plugins.add(window.artplayerPluginChromecast({
+ sdk: '/test/controlled-cast-sdk.js',
+ onStateChange: state => stub.callbacks[index].push(state),
+ onCastStart: () => stub.starts[index]++,
+ onError: error => stub.errors[index].push(String(error)),
+ }))
+ window.registrationBefore.push({ promise: registration instanceof Promise, absent: !art.plugins.artplayerPluginChromecast })
+ await registration
+ art.controls.show = true
+ }
+ }, loaded)
+ await expect.poll(() => page.evaluate(() => window.castPlayers.every(art => art.isReady))).toBe(true)
+ await testInfo.attach('chromecast-controlled-browser-inputs', { contentType: 'application/json', body: JSON.stringify({ core, artifact: process.env.ARTPLAYER_CHROMECAST_ARTIFACT || 'candidate source', sha256: hash(candidate), sdk: 'controlled stub; no receiver, discovery, real SDK or Cast media playback acceptance', native: 'ArtPlayer core, DOM controls, browser click dispatch and local video' }) })
+ return external
+}
+
+const button = (page, index) => page.locator(`#cast-player-${index} .art-control-chromecast`)
+const icon = (page, index) => page.locator(`#cast-player-${index} .art-icon-cast`)
+const loads = page => page.evaluate(() => window.castStub.loads.length)
+
+test.afterEach(async ({ page }, testInfo) => {
+ await page.evaluate(() => {
+ for (const art of window.castPlayers || []) {
+ if (!art.isDestroy)
+ art.destroy()
+ }
+ })
+ await testInfo.attach('chromecast-controlled-browser-state', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ version: window.Artplayer?.version, listeners: window.castStub?.count(), starts: window.castStub?.starts, errors: window.castStub?.errors, callbacks: window.castStub?.callbacks, controls: document.querySelectorAll('.art-control-chromecast').length }))) })
+ expect(await page.evaluate(() => window.castStub?.count() ?? 0)).toBe(0)
+})
+
+for (const core of ['published-5.3.0', 'published', 'candidate']) {
+ test(`${core}: Cast async registration and native clicks update the owning control`, async ({ page }, testInfo) => {
+ const external = await setup(page, core, testInfo)
+ expect(await page.evaluate(() => window.registrationBefore)).toEqual([{ promise: true, absent: true }, { promise: true, absent: true }])
+ await expect(button(page, 0)).toHaveCount(1)
+ await expect(button(page, 1)).toHaveCount(1)
+ await button(page, 1).click()
+ await expect.poll(() => loads(page)).toBe(1)
+ await expect(icon(page, 1)).toHaveCSS('color', 'rgb(255, 0, 0)')
+ expect(await icon(page, 0).evaluate(node => node.style.color)).toBe('')
+ expect(await page.evaluate(() => window.castStub.loads[0].request.media)).toEqual({ contentId: '/test/pattern.mp4?player=1', contentType: 'video/mp4' })
+ await button(page, 1).click()
+ expect(await loads(page)).toBe(1)
+ await page.evaluate(() => window.castStub.loads[0].resolve(undefined))
+ await expect.poll(() => page.evaluate(() => window.castStub.starts)).toEqual([0, 1])
+ await button(page, 0).click()
+ await expect.poll(() => loads(page)).toBe(2)
+ expect(await page.evaluate(() => window.castStub.requests.length)).toBe(1)
+ await page.evaluate(() => {
+ window.castStub.sessionState('SESSION_RESUMED')
+ window.castStub.loads[1].resolve(undefined)
+ })
+ await expect(icon(page, 0)).toHaveCSS('color', 'rgb(255, 0, 0)')
+ await expect.poll(() => page.evaluate(() => window.castStub.starts)).toEqual([1, 1])
+ expect(await page.evaluate(() => [window.castStub.options.length, window.castStub.count()])).toEqual([1, 4])
+ expect(external).toEqual([])
+ })
+
+ test(`${core}: concurrent Cast controls share one local SDK load and release a destroyed waiter`, async ({ page }, testInfo) => {
+ let sdkRoute
+ let finish
+ const held = new Promise(resolve => finish = resolve)
+ const external = await setup(page, core, testInfo, false)
+ const requests = []
+ await page.route('**/test/controlled-cast-sdk.js', async (route) => {
+ requests.push(route.request().url())
+ sdkRoute = route
+ await held
+ await route.fulfill({ contentType: 'text/javascript', body: 'window.installCastStub(); window.__onGCastApiAvailable(true);' })
+ })
+ try {
+ await button(page, 0).click()
+ await button(page, 1).click()
+ await expect.poll(() => Boolean(sdkRoute)).toBe(true)
+ expect(requests).toHaveLength(1)
+ expect(await page.locator('script[src="/test/controlled-cast-sdk.js"]').count()).toBe(1)
+ await page.evaluate(() => window.castPlayers[0].destroy())
+ finish()
+ await expect.poll(() => loads(page)).toBe(1)
+ expect(await page.evaluate(() => [window.castStub.options.length, window.castStub.count(), window.__onGCastApiAvailable === window.previousCastCallback])).toEqual([1, 2, true])
+ expect(await page.evaluate(() => window.castStub.previousCalls)).toEqual([[true, undefined]])
+ await page.evaluate(() => window.castStub.loads[0].resolve(undefined))
+ await expect.poll(() => page.evaluate(() => window.castStub.starts)).toEqual([0, 1])
+ expect(await page.evaluate(() => window.castStub.callbacks[0])).toEqual([])
+ expect(external).toEqual([])
+ }
+ finally { finish() }
+ })
+
+ test(`${core}: Cast terminal states clear native controls and suppress stale media success`, async ({ page }, testInfo) => {
+ const external = await setup(page, core, testInfo)
+ for (const [index, state] of ['SESSION_ENDED', 'SESSION_START_FAILED', 'NO_SESSION'].entries()) {
+ await button(page, 1).click()
+ await expect.poll(() => loads(page)).toBe(index + 1)
+ await expect(icon(page, 1)).toHaveCSS('color', 'rgb(255, 0, 0)')
+ await page.evaluate(() => window.castStub.sessionState('SESSION_ENDING'))
+ await expect(icon(page, 1)).toHaveCSS('color', 'rgb(255, 165, 0)')
+ await page.evaluate((state) => {
+ window.castStub.current = null
+ window.castStub.sessionState(state)
+ }, state)
+ await expect(icon(page, 1)).toHaveCSS('color', 'rgb(255, 255, 255)')
+ expect(await page.evaluate(() => {
+ const plugin = window.castPlayers[1].plugins.artplayerPluginChromecast
+ return [plugin.getCastState(), plugin.isCasting()]
+ })).toEqual([state, false])
+ await page.evaluate(index => window.castStub.loads[index].resolve(undefined), index)
+ expect(await page.evaluate(() => window.castStub.starts)).toEqual([0, 0])
+ }
+ await button(page, 1).click()
+ await expect.poll(() => loads(page)).toBe(4)
+ await page.evaluate(() => window.castStub.loads[3].resolve(undefined))
+ await expect.poll(() => page.evaluate(() => window.castStub.starts)).toEqual([0, 1])
+ expect(external).toEqual([])
+ })
+
+ test(`${core}: destroy unbinds Cast listeners during pending session and media operations`, async ({ page }, testInfo) => {
+ const external = await setup(page, core, testInfo)
+ await page.evaluate(() => window.castStub.deferredRequest = true)
+ await button(page, 0).click()
+ await expect.poll(() => page.evaluate(() => window.castStub.requests.length)).toBe(1)
+ expect(await page.evaluate(() => window.castStub.count())).toBe(2)
+ await page.evaluate(() => {
+ window.castPlayers[0].destroy()
+ window.castStub.requests[0].accept()
+ })
+ expect(await loads(page)).toBe(0)
+ expect(await page.evaluate(() => [window.castStub.count(), window.castPlayers[0].plugins.artplayerPluginChromecast.isCasting()])).toEqual([0, false])
+ await button(page, 1).click()
+ await expect.poll(() => loads(page)).toBe(1)
+ await page.evaluate(() => {
+ window.castPlayers[1].destroy()
+ window.castStub.loads[0].resolve(undefined)
+ window.castStub.sessionState('SESSION_STARTED')
+ })
+ expect(await page.evaluate(() => [window.castStub.count(), window.castStub.starts, window.castStub.callbacks])).toEqual([0, [0, 0], [[], []]])
+ await expect(button(page, 0)).toHaveCount(0)
+ await expect(button(page, 1)).toHaveCount(0)
+ expect(external).toEqual([])
+ })
+
+ test(`${core}: native Cast click reports media rejection once and permits retry`, async ({ page }, testInfo) => {
+ const external = await setup(page, core, testInfo)
+ await button(page, 1).click()
+ await expect.poll(() => loads(page)).toBe(1)
+ await page.evaluate(() => window.castStub.loads[0].reject(new Error('Controlled media load failure')))
+ await expect.poll(() => page.evaluate(() => window.castStub.errors)).toEqual([[], ['Error: Controlled media load failure']])
+ await expect(page.locator('#cast-player-1 .art-notice')).toContainText('Error casting media')
+ expect(await page.evaluate(() => window.castStub.starts)).toEqual([0, 0])
+ await button(page, 1).click()
+ await expect.poll(() => loads(page)).toBe(2)
+ await page.evaluate(() => window.castStub.loads[1].resolve(undefined))
+ await expect.poll(() => page.evaluate(() => window.castStub.starts)).toEqual([0, 1])
+ await expect(page.locator('#cast-player-1 .art-notice')).toContainText('Casting started')
+ expect(await page.evaluate(() => window.castStub.errors)).toEqual([[], ['Error: Controlled media load failure']])
+ expect(external).toEqual([])
+ })
+}
diff --git a/test/chromecast-runtime.test.js b/test/chromecast-runtime.test.js
new file mode 100644
index 000000000..08249500a
--- /dev/null
+++ b/test/chromecast-runtime.test.js
@@ -0,0 +1,629 @@
+import assert from 'node:assert/strict'
+import { Buffer } from 'node:buffer'
+import fs from 'node:fs'
+import process from 'node:process'
+// eslint-disable-next-line test/no-import-node-test -- Candidate SDK behavior uses the repository runner.
+import test from 'node:test'
+import { setImmediate as nextTurn } from 'node:timers/promises'
+import { pathToFileURL } from 'node:url'
+import { chromecastEnvironment } from './helpers/chromecast.js'
+import { compilePackage } from './helpers/load.js'
+
+const artifact = process.env.ARTPLAYER_CHROMECAST_ARTIFACT
+const implementation = { code: artifact ? fs.readFileSync(artifact, 'utf8') : await compilePackage('artplayer-plugin-chromecast', 'umd') }
+
+function environment(options) {
+ const env = chromecastEnvironment(implementation, options)
+ const timers = new Map()
+ let next = 0
+ env.context.setTimeout = (callback, delay) => {
+ const id = ++next
+ timers.set(id, { callback, delay })
+ return id
+ }
+ env.context.clearTimeout = id => timers.delete(id)
+ return { ...env, timers, player(...args) {
+ const player = env.player(...args)
+ const add = player.art.controls.add
+ player.art.controls.add = (option) => {
+ const element = add(option)
+ option.mounted?.call(player.art, element)
+ // The actual Control.add override has no return, unlike Component.add.
+ return undefined
+ }
+ return player
+ } }
+}
+
+function deferred() {
+ let resolve
+ let reject
+ const promise = new Promise((yes, no) => {
+ resolve = yes
+ reject = no
+ })
+ return { promise, resolve, reject }
+}
+
+for (const script of [false, true]) {
+ test(`Chromecast candidate ${script ? 'global' : 'CJS'}: lazy async registration and custom DOM contract`, async () => {
+ const env = environment({ script })
+ assert.equal(env.factory.default, env.factory)
+ const player = env.player()
+ const register = env.factory({ icon: 'custom' })
+ assert.equal(player.controls.length, 0)
+ const pending = register(player.art)
+ assert.equal(typeof pending.then, 'function')
+ assert.equal(player.controls.length, 1)
+ const plugin = await pending
+ assert.deepEqual(Object.keys(plugin), ['name', 'getCastState', 'isCasting'])
+ assert.equal(plugin.name, 'artplayerPluginChromecast')
+ assert.equal(plugin.getCastState(), null)
+ assert.equal(plugin.isCasting(), false)
+ assert.equal(player.controls[0].name, 'chromecast')
+ assert.equal(player.controls[0].position, 'right')
+ assert.equal(player.controls[0].tooltip, 'Chromecast')
+ assert.equal(player.controls[0].html, 'custom')
+ assert.equal(env.scripts.length, 0)
+ assert.equal(env.timers.size, 0)
+ player.destroy()
+ })
+}
+
+test('Chromecast candidate: native ESM default and historical factory default alias retain identity', async () => {
+ const esmArtifact = process.env.ARTPLAYER_CHROMECAST_ESM_ARTIFACT
+ const namespace = esmArtifact
+ ? await import(pathToFileURL(esmArtifact))
+ : await import(`data:text/javascript;base64,${Buffer.from(await compilePackage('artplayer-plugin-chromecast', 'es')).toString('base64')}`)
+ assert.equal(typeof namespace.default, 'function')
+ assert.equal(namespace.default.default, namespace.default)
+ assert.equal(Object.getOwnPropertyDescriptor(namespace.default, 'default').writable, true)
+ assert.equal(typeof namespace.default({}), 'function')
+})
+
+test('Chromecast candidate: existing complete SDK immediately installs listeners without loading another script', async () => {
+ const env = environment()
+ env.installSdk()
+ const player = env.player()
+ const plugin = await env.factory({})(player.art)
+ const pending = player.controls[0].click()
+ env.sessionState('SESSION_STARTED')
+ assert.equal(plugin.getCastState(), 'SESSION_STARTED')
+ assert.equal(player.icon.style.color, 'red')
+ await pending
+ assert.equal(env.scripts.length, 0)
+ assert.equal(env.sdkOptions.length, 1)
+ assert.equal(env.requests(), 0)
+ assert.equal(env.mediaLoads.length, 1)
+ assert.equal(player.art.notice.show, 'Casting started')
+ player.destroy()
+})
+
+test('Chromecast candidate: availability synchronously installs listeners and preserves live callbacks and callback this', async () => {
+ const env = environment()
+ const player = env.player('/movie.M3U8?token=one#position')
+ const events = []
+ const option = { sdk: '/sender.js', onStateChange(state) {
+ assert.equal(this, option)
+ events.push([state, player.icon.style.color])
+ }, onCastStart() { assert.equal(this, option) } }
+ const plugin = await env.factory(option)(player.art)
+ const pending = player.controls[0].click()
+ assert.equal(env.scripts[0].src, '/sender.js')
+ env.installSdk()
+ env.context.__onGCastApiAvailable(true)
+ env.sessionState('SESSION_STARTED')
+ assert.equal(plugin.getCastState(), 'SESSION_STARTED')
+ assert.deepEqual(events, [['connected', undefined]])
+ assert.equal(player.icon.style.color, 'red')
+ await pending
+ assert.equal(env.mediaLoads[0].media.contentType, 'application/x-mpegURL')
+ assert.equal(env.timers.size, 0)
+ player.art.option.url = '/changed.webm'
+ option.onCastStart = function () {
+ assert.equal(this, option)
+ events.push(['live'])
+ }
+ await player.controls[0].click()
+ assert.equal(env.mediaLoads[1].media.contentId, '/changed.webm')
+ option.url = '/override'
+ option.mimeType = 'application/custom'
+ await player.controls[0].click()
+ assert.equal(env.mediaLoads[2].media.contentId, '/override')
+ assert.equal(env.mediaLoads[2].media.contentType, 'application/custom')
+ assert.deepEqual(events.slice(-2), [['live'], ['live']])
+ player.destroy()
+})
+
+test('Chromecast candidate: concurrent loading deduplicates scripts, preserves prior handler, and isolates uninitialized factory instances', async () => {
+ const env = environment()
+ const calls = []
+ function prior(...args) {
+ calls.push(args)
+ }
+ env.context.__onGCastApiAvailable = prior
+ const register = env.factory({ sdk: '/first.js' })
+ const first = env.player()
+ const second = env.player()
+ const idle = env.player()
+ const firstPlugin = await register(first.art)
+ const secondPlugin = await register(second.art)
+ const idlePlugin = await register(idle.art)
+ const one = first.controls[0].click()
+ const two = second.controls[0].click()
+ assert.equal(env.scripts.length, 1)
+ env.installSdk()
+ env.context.__onGCastApiAvailable(true, 'ready')
+ env.sessionState('SESSION_STARTED')
+ await Promise.all([one, two])
+ assert.deepEqual(calls, [[true, 'ready']])
+ assert.equal(env.context.__onGCastApiAvailable, prior)
+ assert.equal(env.sdkOptions.length, 1)
+ assert.equal(firstPlugin.isCasting(), true)
+ assert.equal(secondPlugin.isCasting(), true)
+ assert.equal(first.icon.style.color, 'red')
+ assert.equal(second.icon.style.color, 'red')
+ assert.equal(idle.icon.style.color, undefined)
+ assert.equal(idlePlugin.isCasting(), false)
+ assert.equal(idlePlugin.getCastState(), null)
+ first.destroy()
+ assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
+ env.sessionState('SESSION_ENDED', null)
+ assert.equal(second.icon.style.color, 'white')
+ assert.equal(first.icon.style.color, 'red')
+ second.destroy()
+ idle.destroy()
+})
+
+test('Chromecast candidate: successful undefined requestSession completion resolves current session and concurrent clicks share one operation', async () => {
+ const env = environment()
+ const { castContext, session } = env.installSdk()
+ const requested = deferred()
+ let current = null
+ let requests = 0
+ castContext.getCurrentSession = () => current
+ castContext.requestSession = () => {
+ requests++
+ return requested.promise
+ }
+ const player = env.player()
+ await env.factory({})(player.art)
+ const one = player.controls[0].click()
+ const two = player.controls[0].click()
+ assert.equal(one, two)
+ await env.flush()
+ assert.equal(requests, 1)
+ current = session
+ requested.resolve(undefined)
+ await one
+ assert.equal(env.mediaLoads.length, 1)
+ player.destroy()
+})
+
+for (const mode of ['missing-session', 'request-reject', 'media-reject', 'configure-throw']) {
+ test(`Chromecast candidate: ${mode} settles click with one original error callback and stage notice`, async () => {
+ const env = environment()
+ const error = new Error(mode)
+ const { castContext } = env.installSdk(mode === 'media-reject' ? { loadError: error } : {})
+ if (mode === 'configure-throw')
+ castContext.setOptions = () => { throw error }
+ if (mode === 'missing-session' || mode === 'request-reject') {
+ castContext.getCurrentSession = () => null
+ if (mode === 'request-reject')
+ castContext.requestSession = () => Promise.reject(error)
+ }
+ const errors = []
+ const option = { onError(value) {
+ assert.equal(this, option)
+ errors.push(value)
+ } }
+ const player = env.player()
+ await env.factory(option)(player.art)
+ await assert.rejects(player.controls[0].click(), value => mode === 'missing-session' ? value.message === 'No active Cast session' : value === error)
+ assert.equal(errors.length, 1)
+ assert.equal(player.art.notice.show, mode === 'media-reject' ? 'Error casting media' : mode === 'configure-throw' ? 'Failed to initialize Cast API' : 'Error connecting to cast session')
+ if (mode !== 'media-reject')
+ assert.equal(env.mediaLoads.length, 0)
+ player.destroy()
+ })
+}
+
+test('Chromecast candidate: configure errors inside availability do not escape global callback and permit retry', async () => {
+ const env = environment()
+ const player = env.player()
+ await env.factory({})(player.art)
+ const pending = player.controls[0].click()
+ const { castContext } = env.installSdk()
+ const original = castContext.setOptions
+ const error = new Error('configuration')
+ castContext.setOptions = () => {
+ throw error
+ }
+ assert.doesNotThrow(() => env.context.__onGCastApiAvailable(true))
+ await assert.rejects(pending, value => value === error)
+ castContext.setOptions = original
+ await player.controls[0].click()
+ assert.equal(env.mediaLoads.length, 1)
+ assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
+ player.destroy()
+})
+
+for (const mode of ['script-error', 'unavailable', 'timeout']) {
+ test(`Chromecast candidate: ${mode} restores external callback, removes failed script and permits retry`, async () => {
+ const env = environment()
+ function prior() {}
+ env.context.__onGCastApiAvailable = prior
+ const player = env.player()
+ const errors = []
+ await env.factory({ onError: value => errors.push(value) })(player.art)
+ const pending = player.controls[0].click()
+ const script = env.scripts[0]
+ const original = { type: 'error' }
+ if (mode === 'script-error') {
+ script.onerror(original)
+ }
+ else if (mode === 'unavailable') {
+ env.context.__onGCastApiAvailable(false)
+ }
+ else {
+ script.onload()
+ const timer = [...env.timers.values()][0]
+ assert.equal(timer.delay, 30000)
+ timer.callback()
+ }
+ await assert.rejects(pending, value => mode === 'script-error' ? value === original : value.message.includes(mode === 'timeout' ? 'Timed out' : 'not available'))
+ assert.equal(errors.length, 1)
+ assert.equal(env.context.__onGCastApiAvailable, prior)
+ assert.equal(env.scripts.length, 0)
+ assert.equal(env.timers.size, 0)
+ const retry = player.controls[0].click()
+ assert.equal(env.scripts.length, 1)
+ env.installSdk()
+ env.context.__onGCastApiAvailable(true)
+ await retry
+ assert.equal(env.mediaLoads.length, 1)
+ player.destroy()
+ })
+}
+
+test('Chromecast candidate: asynchronous framework can become ready after script load and the last owner cancels unfinished loading', async () => {
+ const env = environment()
+ const first = env.player()
+ const second = env.player()
+ await env.factory({})(first.art)
+ await env.factory({ sdk: '/different.js' })(second.art)
+ const one = first.controls[0].click()
+ const two = second.controls[0].click()
+ assert.equal(env.scripts.length, 1)
+ assert.match(env.scripts[0].src, /gstatic/)
+ env.scripts[0].onload()
+ assert.equal(env.timers.size, 1)
+ first.destroy()
+ await one
+ assert.equal(env.scripts.length, 1)
+ second.destroy()
+ await two
+ assert.equal(env.scripts.length, 0)
+ assert.equal(env.timers.size, 0)
+ assert.equal(env.context.__onGCastApiAvailable, undefined)
+ assert.equal(env.mediaLoads.length, 0)
+})
+
+test('Chromecast candidate: one destroyed loader subscriber does not strand remaining subscriber', async () => {
+ const env = environment()
+ const first = env.player()
+ const second = env.player()
+ await env.factory({})(first.art)
+ await env.factory({})(second.art)
+ const one = first.controls[0].click()
+ const two = second.controls[0].click()
+ first.destroy()
+ await one
+ env.installSdk()
+ env.context.__onGCastApiAvailable(true)
+ await two
+ assert.equal(env.mediaLoads.length, 1)
+ assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
+ assert.equal(first.art.notice.show, '')
+ second.destroy()
+})
+
+test('Chromecast candidate: an external callback exception preserves external semantics without stranding plugin initialization', async () => {
+ const env = environment()
+ const error = new Error('external')
+ env.context.__onGCastApiAvailable = () => {
+ throw error
+ }
+ const player = env.player()
+ await env.factory({})(player.art)
+ const pending = player.controls[0].click()
+ env.installSdk()
+ assert.throws(() => env.context.__onGCastApiAvailable(true), value => value === error)
+ await pending
+ assert.equal(env.mediaLoads.length, 1)
+ player.destroy()
+})
+
+test('Chromecast candidate: external handler replacement during loading is not overwritten on cleanup', async () => {
+ const env = environment()
+ const player = env.player()
+ await env.factory({})(player.art)
+ const pending = player.controls[0].click()
+ const own = env.context.__onGCastApiAvailable
+ function replacement() {}
+ env.context.__onGCastApiAvailable = replacement
+ env.installSdk()
+ own(true)
+ await pending
+ assert.equal(env.context.__onGCastApiAvailable, replacement)
+ player.destroy()
+})
+
+for (const terminal of ['NO_SESSION', 'SESSION_ENDING', 'SESSION_ENDED', 'SESSION_START_FAILED']) {
+ test(`Chromecast candidate: ${terminal} cancels an in-flight session request and cannot revive casting`, async () => {
+ const env = environment()
+ const { castContext, session } = env.installSdk()
+ env.context.cast.framework.SessionState.SESSION_START_FAILED = 'SESSION_START_FAILED'
+ let current = null
+ const request = deferred()
+ castContext.getCurrentSession = () => current
+ castContext.requestSession = () => request.promise
+ const player = env.player()
+ const calls = []
+ const plugin = await env.factory({ onStateChange: state => calls.push(state), onCastStart: () => calls.push('start') })(player.art)
+ const pending = player.controls[0].click()
+ await env.flush()
+ env.sessionState(terminal, null)
+ await pending
+ current = session
+ request.resolve(undefined)
+ await env.flush()
+ assert.equal(env.mediaLoads.length, 0)
+ assert.equal(plugin.getCastState(), terminal)
+ assert.equal(plugin.isCasting(), false)
+ assert.deepEqual(calls, [terminal === 'SESSION_ENDING' ? 'disconnecting' : 'disconnected'])
+ assert.equal(player.icon.style.color, terminal === 'SESSION_ENDING' ? 'orange' : 'white')
+ player.destroy()
+ })
+}
+
+for (const action of ['destroy', 'ended', 'replacement']) {
+ test(`Chromecast candidate: ${action} during media load suppresses obsolete success and consumes late rejection`, async () => {
+ const env = environment()
+ const { session, castContext } = env.installSdk()
+ const media = deferred()
+ session.loadMedia = () => media.promise
+ const calls = []
+ const player = env.player()
+ const plugin = await env.factory({ onCastStart: () => calls.push('start'), onError: () => calls.push('error') })(player.art)
+ const pending = player.controls[0].click()
+ await env.flush()
+ assert.equal(plugin.isCasting(), true)
+ if (action === 'destroy') {
+ player.destroy()
+ }
+ else if (action === 'ended') {
+ env.sessionState('SESSION_ENDED', null)
+ }
+ else {
+ const other = { loadMedia: () => Promise.resolve() }
+ castContext.getCurrentSession = () => other
+ env.sessionState('SESSION_RESUMED', other)
+ }
+ await pending
+ media.reject(new Error('obsolete'))
+ await env.flush()
+ assert.deepEqual(calls, [])
+ assert.equal(player.art.notice.show, '')
+ if (action === 'destroy') {
+ assert.equal(env.sdkListeners.get('sessionstatechanged').length, 0)
+ assert.equal(env.sdkListeners.get('caststatechanged').length, 0)
+ assert.equal(player.listeners.get('destroy').length, 0)
+ }
+ player.destroy()
+ })
+}
+
+test('Chromecast candidate: reentrant destruction while reading current session never invokes media load', async () => {
+ const env = environment()
+ const { castContext, session } = env.installSdk()
+ const player = env.player()
+ await env.factory({})(player.art)
+ castContext.getCurrentSession = () => {
+ player.destroy()
+ return session
+ }
+ await player.controls[0].click()
+ assert.equal(env.mediaLoads.length, 0)
+})
+
+test('Chromecast candidate: availability and every normalized state retain raw session getters and owned callback ordering', async () => {
+ const env = environment()
+ env.installSdk()
+ env.context.cast.framework.SessionState.SESSION_START_FAILED = 'SESSION_START_FAILED'
+ const player = env.player()
+ const states = []
+ const availability = []
+ const option = { onStateChange(state) {
+ assert.equal(this, option)
+ states.push(state)
+ }, onCastAvailable(value) {
+ assert.equal(this, option)
+ availability.push(value)
+ } }
+ const plugin = await env.factory(option)(player.art)
+ await player.controls[0].click()
+ for (const state of ['SESSION_STARTING', 'SESSION_STARTED', 'SESSION_RESUMED', 'SESSION_ENDING', 'SESSION_ENDED', 'SESSION_START_FAILED', 'NO_SESSION']) {
+ env.sessionState(state)
+ assert.equal(plugin.getCastState(), state)
+ }
+ assert.deepEqual(states, ['connecting', 'connected', 'connected', 'disconnecting', 'disconnected', 'disconnected', 'disconnected'])
+ assert.equal(plugin.isCasting(), false)
+ for (const castState of ['NO_DEVICES_AVAILABLE', 'NOT_CONNECTED', 'CONNECTING', 'CONNECTED', 'unknown'])
+ env.emit('caststatechanged', { castState })
+ assert.deepEqual(availability, [false, true, true, true])
+ player.destroy()
+})
+
+test('Chromecast candidate: synchronous initial no-session replay preserves casting and the availability subscription', async () => {
+ const env = environment()
+ const { castContext } = env.installSdk()
+ const add = castContext.addEventListener
+ castContext.addEventListener = (name, listener) => {
+ add(name, listener)
+ if (name === 'sessionstatechanged')
+ listener({ sessionState: 'NO_SESSION', session: null })
+ }
+ const values = []
+ const player = env.player()
+ await env.factory({ onCastAvailable: value => values.push(value) })(player.art)
+ await player.controls[0].click()
+ assert.equal(env.mediaLoads.length, 1)
+ await player.controls[0].click()
+ env.emit('caststatechanged', { castState: 'NOT_CONNECTED' })
+ assert.deepEqual(values, [true])
+ assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
+ assert.equal(env.sdkListeners.get('caststatechanged').length, 1)
+ player.destroy()
+})
+
+test('Chromecast candidate: initial NO_SESSION immediately after availability still requests and loads a new session', async () => {
+ const env = environment()
+ const player = env.player()
+ const plugin = await env.factory({})(player.art)
+ const pending = player.controls[0].click()
+ const { castContext, session } = env.installSdk()
+ let current = null
+ let requests = 0
+ castContext.getCurrentSession = () => current
+ castContext.requestSession = () => {
+ requests++
+ current = session
+ return Promise.resolve(undefined)
+ }
+ env.context.__onGCastApiAvailable(true)
+ env.sessionState('NO_SESSION', null)
+ await pending
+ assert.equal(requests, 1)
+ assert.equal(env.mediaLoads.length, 1)
+ assert.equal(plugin.getCastState(), 'NO_SESSION', 'Getter remains the raw last SDK event')
+ assert.equal(plugin.isCasting(), true)
+ player.destroy()
+})
+
+test('Chromecast candidate: cleanup attempts all owned listeners and the player subscription after an SDK removal failure', async () => {
+ const env = environment()
+ const { castContext } = env.installSdk()
+ const player = env.player()
+ const plugin = await env.factory({})(player.art)
+ await player.controls[0].click()
+ const remove = castContext.removeEventListener
+ const error = new Error('remove session listener')
+ const names = []
+ castContext.removeEventListener = (name, listener) => {
+ names.push(name)
+ if (name === 'sessionstatechanged')
+ throw error
+ remove(name, listener)
+ }
+ assert.throws(() => player.destroy(), value => value === error)
+ assert.deepEqual(names, ['sessionstatechanged', 'caststatechanged'])
+ assert.equal(env.sdkListeners.get('caststatechanged').length, 0)
+ assert.equal(player.listeners.get('destroy').length, 0)
+ assert.equal(plugin.isCasting(), false)
+ const state = plugin.getCastState()
+ env.sessionState('SESSION_STARTED')
+ assert.equal(plugin.getCastState(), state)
+ assert.doesNotThrow(() => player.destroy())
+})
+
+test('Chromecast candidate: partial subscription failure preserves the setup error and retries complete initialization', async () => {
+ const env = environment()
+ const { castContext } = env.installSdk()
+ const add = castContext.addEventListener
+ const remove = castContext.removeEventListener
+ const setupError = new Error('add availability')
+ const cleanupError = new Error('remove after add failed')
+ castContext.addEventListener = (name, listener) => {
+ if (name === 'caststatechanged')
+ throw setupError
+ add(name, listener)
+ }
+ castContext.removeEventListener = (name, listener) => {
+ remove(name, listener)
+ if (name === 'sessionstatechanged')
+ throw cleanupError
+ }
+ const errors = []
+ const player = env.player()
+ await env.factory({ onError: error => errors.push(error) })(player.art)
+ await assert.rejects(player.controls[0].click(), value => value === setupError)
+ assert.deepEqual(errors, [setupError])
+ assert.equal(env.sdkListeners.get('sessionstatechanged').length, 0)
+ castContext.addEventListener = add
+ castContext.removeEventListener = remove
+ await player.controls[0].click()
+ assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
+ assert.equal(env.sdkListeners.get('caststatechanged').length, 1)
+ player.destroy()
+})
+
+test('Chromecast candidate: ignored DOM-dispatch click rejection is observed internally without duplicate reporting', async () => {
+ const env = environment()
+ const error = new Error('receiver load failed')
+ env.installSdk({ loadError: error })
+ const player = env.player()
+ const errors = []
+ await env.factory({ onError: value => errors.push(value) })(player.art)
+ // The core DOM dispatcher calls click and ignores its return value.
+ player.controls[0].click()
+ await nextTurn()
+ assert.deepEqual(errors, [error])
+ assert.equal(player.art.notice.show, 'Error casting media')
+ await assert.rejects(player.controls[0].click(), value => value === error)
+ assert.deepEqual(errors, [error, error])
+ player.destroy()
+})
+
+test('Chromecast candidate: synchronous user callback reentry shares the guarded click operation', async () => {
+ const env = environment()
+ const { castContext } = env.installSdk()
+ const add = castContext.addEventListener
+ castContext.addEventListener = (name, listener) => {
+ add(name, listener)
+ if (name === 'sessionstatechanged')
+ listener({ sessionState: 'NO_SESSION', session: null })
+ }
+ let reentered
+ const player = env.player()
+ await env.factory({ onStateChange() {
+ if (!reentered)
+ reentered = player.controls[0].click()
+ } })(player.art)
+ const pending = player.controls[0].click()
+ assert.equal(reentered, pending)
+ await pending
+ assert.equal(env.mediaLoads.length, 1)
+ assert.equal(env.sdkListeners.get('sessionstatechanged').length, 1)
+ player.destroy()
+})
+
+test('Chromecast candidate: mounted captures the owned icon when Control.add returns undefined', async () => {
+ const env = environment()
+ env.installSdk()
+ const first = env.player()
+ const second = env.player()
+ await env.factory({})(first.art)
+ await env.factory({})(second.art)
+ assert.equal(typeof second.controls[0].mounted, 'function')
+ await second.controls[0].click()
+ env.sessionState('SESSION_STARTED')
+ assert.equal(second.icon.style.color, 'red')
+ assert.equal(first.icon.style.color, undefined)
+ env.sessionState('SESSION_ENDING')
+ assert.equal(second.icon.style.color, 'orange')
+ env.sessionState('SESSION_ENDED', null)
+ assert.equal(second.icon.style.color, 'white')
+ first.destroy()
+ second.destroy()
+})