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