refactor(chromecast): [PKG-CAST-03] isolate SDK and player lifecycles

This commit is contained in:
Harvey Zhao committed 2026-09-13 17:13:35 +08:00
1 parent 87cfcf5a6c
commit b0cfbfe3a0
26 files changed
+2503 -551

No files matched your search

+1 -1
View File
@@ -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:`<i class="art-icon art-icon-cast">${e.icon||'<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>'}</i>`,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}Line truncated
@@ -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:`<i class="art-icon art-icon-cast">${t.icon||'<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>'}</i>`,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.relLine truncated
+347 -161
View File
@@ -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 = '<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h17v14h-8M3 11a10 10 0 0 1 10 10M3 16a5 5 0 0 1 5 5"/><circle cx="3" cy="21" r="1" fill="currentColor" stroke="none"/></svg>\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 = `<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>`;
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: `<i class="art-icon art-icon-cast">${option.icon || DEFAULT_ICON}</i>`,
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: `<i class="art-icon art-icon-cast">${option.icon || icon}</i>`,
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
};
+2 -2
View File
@@ -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": {
@@ -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.
@@ -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:`<i class="art-icon art-icon-cast">${e.icon||'<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>'}</i>`,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}Line truncated
@@ -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:`<i class="art-icon art-icon-cast">${t.icon||'<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>'}</i>`,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.relLine truncated
@@ -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 = '<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h17v14h-8M3 11a10 10 0 0 1 10 10M3 16a5 5 0 0 1 5 5"/><circle cx="3" cy="21" r="1" fill="currentColor" stroke="none"/></svg>\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 = `<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>`;
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: `<i class="art-icon art-icon-cast">${option.icon || DEFAULT_ICON}</i>`,
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: `<i class="art-icon art-icon-cast">${option.icon || icon}</i>`,
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
};
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>
<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h17v14h-8M3 11a10 10 0 0 1 10 10M3 16a5 5 0 0 1 5 5"/><circle cx="3" cy="21" r="1" fill="currentColor" stroke="none"/></svg>

Before

Width:  |  Height:  |  Size: 691 B

After

Width:  |  Height:  |  Size: 321 B

@@ -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<void> | undefined
let releaseLoader: (() => void) | undefined
let generation = 0
let operationStarted = false
let cancelOperation: (() => void) | undefined
let cancel!: () => void
const cancelled = new Promise<undefined>((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<HTMLElement>('.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<undefined>((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<void>((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: `<i class="art-icon art-icon-cast">${option.icon || icon}</i>`,
mounted(control) {
if (active())
element = control
},
click,
})
art.on('destroy', destroy)
if (art.isDestroy)
destroy()
return { name: 'artplayerPluginChromecast', getCastState: () => state, isCasting: () => session !== 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 = `<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>`
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: `<i class="art-icon art-icon-cast">${option.icon || DEFAULT_ICON}</i>`,
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,
}
}
}
@@ -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 })
@@ -0,0 +1,23 @@
import type { CastSdk, CastSession, ChromecastOptions } from './types'
const mimeTypes: Record<string, string> = {
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<unknown> {
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))
}
@@ -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<Subscriber>, cancel: () => void }
const pending = new WeakMap<CastWindow, Loading>()
const configured = new WeakSet<CastContext>()
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<CastSdk>((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<CastSdk>((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
}
@@ -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<unknown> }
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<unknown>
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<string, string>
CastState: Record<string, string>
}
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
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts", "../../types/assets.d.ts"]
}
@@ -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
}
@@ -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)。独立本地完成提交,不推送或发布。
+3 -2
View File
@@ -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<br>核对包契约与历史用法 | BASE-05 | url/sdk/icon/mimeType、loader、会话、全局 SDK 与结果 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done |
| PKG-CAST-02 | artplayer-plugin-chromecast<br>建立特有行为与错误测试 | PKG-CAST-01, ENG-03, ENG-05 | SDK 脚本加载失败/重入、无设备/拒绝、重复实例与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-CAST-03 | artplayer-plugin-chromecast<br>整理内部职责与资源 | PKG-CAST-02, CORE-11, CORE-13 | SDK singleton/实例订阅/会话与控件职责分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | doing |
| PKG-CAST-03 | artplayer-plugin-chromecast<br>整理内部职责与资源 | PKG-CAST-02, CORE-11, CORE-13 | SDK singleton/实例订阅/会话与控件职责分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
| PKG-CAST-04 | artplayer-plugin-chromecast<br>迁移自有源码和公开类型 | PKG-CAST-03, ENG-04, ENG-06, CORE-07 | Cast 能力 adapter、全局对象和元数据类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-CAST-05 | artplayer-plugin-chromecast<br>验证新旧核心和组合 | PKG-CAST-04, CORE-22 | stub 测试之外记录实际 Cast 设备会话、源更新和断开 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-CAST-06 | artplayer-plugin-chromecast<br>验证分发并同步文档 | 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)
+11
View File
@@ -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准确
+6 -6
View File
@@ -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 |
+55 -13
View File
@@ -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."
}
]
}
+5 -2
View File
@@ -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",
+14 -10
View File
@@ -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"
},
{
+261
View File
@@ -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([])
})
}
+629
View File
@@ -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: '<b>custom</b>' })
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, '<i class="art-icon art-icon-cast"><b>custom</b></i>')
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()
})