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

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

No files matched your search

@@ -0,0 +1,131 @@
# Chromecast maintenance
The factory remains `artplayerPluginChromecast(option)`. Registration is async and
adds the `chromecast` right control immediately. Its resolved object retains
`name`, `getCastState()` and `isCasting()`. SDK loading remains lazy on first click.
The option object stays live: later changes to `url`, `mimeType`, `sdk` and callback
properties are read at the corresponding operation. Callbacks retain `this === option`.
The control HTML is captured at registration, as before.
## Modules
| File | Responsibility |
| --- | --- |
| `src/index.ts` | Public factory and historical `.default` factory alias |
| `src/controller.ts` | One registration's subscriptions, raw session state, click operation, notices and owned control icon |
| `src/sdk.ts` | Framework readiness, shared in-flight script leases, callback preservation and context configuration |
| `src/media.ts` | Existing URL/extension MIME rules and SDK media request construction |
| `src/types.ts` | Narrow internal SDK, event and callback contracts; not the published declaration entrypoint |
| `src/cast.svg` | Original default Cast artwork imported as inline SVG |
Dependencies point from the factory to the controller, then to the SDK loader and
media builder. The loader and media builder do not receive an ArtPlayer instance.
Public declarations remain in `types/`; their historical mismatch with async
registration is tracked separately by PKG-CAST-04. Internal strict typing does not
silently change those declarations.
## Initialization and ownership
Each registration has its own session state and subscriptions, even when one
factory is reused for several players. Unclicked instances do not subscribe to
the SDK. An initialized instance observes page-wide CastContext events; this is
the SDK's shared session, not an exclusively owned receiver session.
Within a loaded plugin module, controllers share one pending SDK load per window.
The first pending request's SDK URL wins; another URL can be used on a later retry.
A complete existing Framework is reused immediately. A partial `chrome.cast`
object is insufficient readiness and still permits loading the Framework script.
The original availability callback is invoked with its window receiver and both
arguments. Its thrown exception still escapes to its caller, while the plugin's
own subscribers settle independently. Cleanup restores that handler only while
our handler is still installed, so a later external replacement is preserved.
On availability, each subscriber installs its listeners synchronously before its
loader promise resolves. This preserves the historical boundary where a session
event immediately following `__onGCastApiAvailable(true)` is observable. One
subscriber's setup exception rejects that subscriber without stranding the rest.
The default receiver and ORIGIN_SCOPED policy are configured once per observed
context. This does not negotiate receiver configuration with unrelated Cast apps.
Successful scripts remain on the page. Failed or last-owner-cancelled scripts are
removed and can be retried. Script `load` may precede Framework availability, so
incomplete load does not fail immediately; a 30-second readiness timeout settles
an unavailable Framework even if no error/availability event arrives. The timeout
is cleared on success, failure and last-owner cancellation. Destroying one player
releases its lease without interrupting another pending player. Independently
loaded copies of this plugin do not share their module-local lease registry.
## Session operations and failure semantics
One player has at most one pending click operation; concurrent clicks receive the
same promise. After initialization, the controller reads `getCurrentSession()`.
If absent, it awaits `requestSession()` and reads `getCurrentSession()` again.
The request's completion value is not a CastSession. No active session after a
successful request is a connection error. Media URL selection uses the current
`option.url || art.option.url`; no receiver URL rewriting is performed.
The complete media load is awaited. The click promise rejects with the original
SDK error and reports one stage-specific notice and `onError`. A separate rejection
observer handles ArtPlayer's DOM dispatcher, which ignores returned click promises;
programmatic callers still receive the original rejecting promise. Callback
exceptions retain their normal propagation and are not interpreted as receiver
success evidence.
`getCastState()` retains the last raw SDK SessionState, initially null, and is not
the normalized callback state. `isCasting()` reflects a retained session reference,
including one acquired by a successful session request; it does not prove receiver
playback. `NO_SESSION`, `SESSION_ENDED` and `SESSION_START_FAILED` clear that reference
and notify disconnected. Initial `NO_SESSION` during SDK setup does not cancel the
first click before a session operation begins. Ending/terminal events during a
request or media load, and replacement of the current session, invalidate pending
work. Late success/rejection is observed without stale media starts or notices.
Destroy marks the controller inactive, settles its pending click, releases its
loader lease, attempts both SDK listener removals and removes its ArtPlayer
subscription. All cleanup is attempted even when one SDK removal throws; the first
cleanup error is preserved. Any callback left behind by a broken SDK removal is
inactive. The plugin never ends a page-shared session. ArtPlayer owns control DOM
removal; the plugin only updates the icon under the element supplied to its own
control's `mounted` callback, retaining `.art-icon.art-icon-cast` and the
red/orange/white states. The actual `Control.add` override returns undefined even
though the shared component superclass returns an element; never rely on the
control add return value for DOM ownership. Candidate fixtures replay `mounted`
and preserve this undefined return so the browser-discovered regression remains
covered outside the browser too.
## Artwork provenance
The default SVG now uses original rectangle, circular arc and dot geometry authored
for this refactor under the repository MIT license. It does not copy the former
Font Awesome Pro path. `src/cast.svg` is the single artwork source; custom `icon`
HTML and the existing wrapper hooks remain supported. The previous commercial
notice/source gap is tracked as VENDOR-10; production bundles must be regenerated
from this source rather than edited by hand.
## Validation and next changes
Use the pinned Node and Yarn versions. Scoped commands from the repository root:
```sh
node --test test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js
yarn exec tsc -p packages/artplayer-plugin-chromecast/tsconfig.json
yarn exec eslint packages/artplayer-plugin-chromecast/src test/chromecast-runtime.test.js
yarn build artplayer-plugin-chromecast
```
`ARTPLAYER_CHROMECAST_ARTIFACT` selects a built main or legacy file for the candidate
tests. `ARTPLAYER_CHROMECAST_ESM_ARTIFACT` selects the native ESM artifact for the
default-export identity check. Without these variables tests bundle current source
in memory. Historical tests retain actual frozen npm/source inputs and deliberately
observe the old defects; candidate tests assert their corrections separately.
Change SDK loading in `sdk.ts`, state/cleanup in `controller.ts`, and MIME behavior
in `media.ts`. Always retain synchronous availability-event coverage, ignored DOM
promise rejection coverage, multi-player ownership, partial failure cleanup and
late completion tests when changing those boundaries.
Controlled SDK tests and local browser DOM interaction cannot establish real Cast
receiver playback. PKG-CAST-05 still requires the supported Chrome/HTTPS sender,
actual receiver, network-reachable media, source changes and disconnection evidence.
The default SDK URL is mutable; no remote SDK version, physical receiver or Safari
support is established by this source migration.
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginChromecast=t(),define(function(){return t.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";return function(e){let t=!1,o=null,n=null;const a=e=>{const t=document.querySelector(".art-icon-cast");if(t)switch(e){case"connected":t.style.color="red";break;case"connecting":case"disconnecting":t.style.color="orange";break;default:t.style.color="white"}},c=()=>new Promise((c,s)=>{var i;window.__onGCastApiAvailable=i=>{if(i){const s=window.cast.framework.CastContext.getInstance();s.setOptions({receiverApplicationId:window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED}),s.addEventListener(window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,t=>{const c=window.cast.framework.SessionState;switch(n=t.sessionState,o=t.session,t.sessionState){case c.NO_SESSION:e.onStateChange?.("disconnected"),a("disconnected");break;case c.SESSION_STARTING:e.onStateChange?.("connecting"),a("connecting");break;case c.SESSION_STARTED:e.onStateChange?.("connected"),a("connected");break;case c.SESSION_ENDING:e.onStateChange?.("disconnecting"),a("disconnecting");break;case c.SESSION_RESUMED:e.onStateChange?.("connected"),a("connected")}}),s.addEventListener(window.cast.framework.CastContextEventType.CAST_STATE_CHANGED,t=>{const o=window.cast.framework.CastState;switch(t.castState){case o.NO_DEVICES_AVAILABLE:e.onCastAvailable?.(!1);break;case o.NOT_CONNECTED:case o.CONNECTING:case o.CONNECTED:e.onCastAvailable?.(!0)}}),t=!0,c()}else s(new Error("Cast API is not available"))},window.chrome&&window.chrome.cast||(i=e.sdk||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",new Promise((e,t)=>{const o=document.createElement("script");o.src=i,o.onload=e,o.onerror=t,document.body.appendChild(o)})).catch(s)}),s=(t,o)=>{const n=e.url||t.option.url,a=new window.chrome.cast.media.MediaInfo(n,e.mimeType||function(e){return{mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"}[e.split("?")[0].split("#")[0].split(".").pop().toLowerCase()]||"application/octet-stream"}(n)),c=new window.chrome.cast.media.LoadRequest(a);o.loadMedia(c).then(()=>{t.notice.show="Casting started",e.onCastStart?.()}).catch(o=>{throw t.notice.show="Error casting media",e.onError?.(o),o})};return async a=>(a.controls.add({name:"chromecast",position:"right",tooltip:"Chromecast",html:`<i class="art-icon art-icon-cast">${e.icon||'<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>'}</i>`,click:async()=>{if(!t)try{await c()}catch(i){throw a.notice.show="Failed to initialize Cast API",e.onError?.(i),i}const n=window.cast.framework.CastContext.getInstance();if(o)s(a,o);else try{const e=await n.requestSession();s(a,e)}catch(i){throw a.notice.show="Error connecting to cast session",e.onError?.(i),i}}}),{name:"artplayerPluginChromecast",getCastState:()=>n,isCasting:()=>null!==o})}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginChromecast=t(),define(function(){return e.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";const e={mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"};function t(t,o,n,i){const r=n.url||i,s=r.split("?")[0].split("#")[0].split(".").pop().toLowerCase(),a=new t.media.MediaInfo(r,n.mimeType||e[s]||"application/octet-stream");return o.loadMedia(new t.media.LoadRequest(a))}const o=new WeakMap,n=new WeakSet;function i(e){const t=e.cast?.framework,o=e.chrome?.cast;if("function"===typeof t?.CastContext?.getInstance&&t.CastContextEventType&&t.SessionState&&t.CastState&&"function"===typeof o?.media?.MediaInfo&&"function"===typeof o.media.LoadRequest&&o.AutoJoinPolicy)return{framework:t,media:o.media,autoJoinPolicy:o.AutoJoinPolicy.ORIGIN_SCOPED}}function r(e,t){let o;const n=new Promise((n,i)=>{o={ready:t,resolve:n,reject:i},e.subscribers.add(o)});let i=!1;return{promise:n,release:()=>{i||(i=!0,e.subscribers.delete(o),o.reject(new Error("Cast initialization cancelled")),e.subscribers.size||e.cancel())}}}function s(e,s){let a,c,l,d,u,f,m=!1,v=null,S=null,p=0,E=!1;const w=new Promise(e=>{f=()=>e(void 0)});let C;const h=()=>!m&&!e.isDestroy,y=e=>{if(s.onStateChange?.(e),!h())return;const t=C?.querySelector(".art-icon-cast");t&&(t.style.color="connected"===e?"red":"connecting"===e||"disconnecting"===e?"orange":"white")},g=e=>{if(!h()||!a)return;S=e.sessionState;const t=a.framework.SessionState;S===t.NO_SESSION||S===t.SESSION_ENDED||S===t.SESSION_START_FAILED?((S!==t.NO_SESSION||E)&&(p++,u?.()),v=null,y("disconnected")):((S===t.SESSION_ENDING||v&&e.session&&e.session!==v)&&(p++,u?.()),v=e.session||null,S===t.SESSION_STARTING?y("connecting"):S===t.SESSION_ENDING?y("disconnecting"):S!==t.SESSION_STARTED&&S!==t.SESSION_RESUMED||y("connected"))},A=e=>{if(!h()||!a)return;const t=a.framework.CastState;e.castState===t.NO_DEVICES_AVAILABLE?s.onCastAvailable?.(!1):e.castState!==t.NOT_CONNECTED&&e.castState!==t.CONNECTING&&e.castState!==t.CONNECTED||s.onCastAvailable?.(!0)},_=()=>{const e=c,t=a;if(c=void 0,a=void 0,e&&t){const n=t.framework.CastContextEventType,i=[];try{e.removeEventListener(n.SESSION_STATE_CHANGED,g)}catch(o){i.push(o)}try{e.removeEventListener(n.CAST_STATE_CHANGED,A)}catch(o){i.push(o)}if(i.length)throw i[0]}},N=()=>{if(m)return;m=!0,f();const t=d;d=void 0;const o=[];for(const i of[t,_,()=>e.off("destroy",N)])try{i?.()}catch(n){o.push(n)}if(v=null,C=void 0,o.length)throw o[0]};async function I(){const l=p,f=()=>h()&&p===l;let m;const S=new Promise(e=>{m=()=>e(void 0)});u=m;let C="Failed to initialize Cast API";try{if(!a||!c){const e=function(e,t){const n=window,s=i(n);if(s)return{promise:new Promise((e,o)=>{try{t(s),e(s)}catch(n){o(n)}}),release:()=>{}};const a=o.get(n);if(a)return r(a,t);const c={subscribers:new Set,cancel:()=>v(void 0,new Error("Cast initialization cancelled"))};o.set(n,c);const l=r(c,t),d=n.__onGCastApiAvailable;let u,f,m=!1;function v(e,t){if(!m){m=!0,void 0!==f&&n.clearTimeout(f),o.delete(n),n.__onGCastApiAvailable===S&&(n.__onGCastApiAvailable=d),u&&(u.onload=null,u.onerror=null,e||u.remove());for(const o of c.subscribers)try{e?(o.ready(e),o.resolve(e)):o.reject(t)}catch(i){o.reject(i)}c.subscribers.clear()}}function S(e,t){try{d?.call(n,e,t)}finally{v(e?i(n):void 0,new Error("Cast API is not available"))}}n.__onGCastApiAvailable=S;try{f=n.setTimeout(()=>v(void 0,new Error("Timed out initializing Cast API")),3e4),u=document.createElement("script"),u.src=e||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",u.onerror=e=>v(void 0,e),u.onload=()=>{const e=i(n);e&&v(e)},document.body.appendChild(u)}catch(p){v(void 0,p)}return l}(s.sdk,e=>{if(!f())return;if(a=e,c=function(e){const t=e.framework.CastContext.getInstance();return n.has(t)||(t.setOptions({receiverApplicationId:e.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:e.autoJoinPolicy}),n.add(t)),t}(a),!f())return;const t=a.framework.CastContextEventType;try{c.addEventListener(t.SESSION_STATE_CHANGED,g),h()&&c.addEventListener(t.CAST_STATE_CHANGED,A)}catch(o){try{_()}catch{}throw o}});d=e.release,await Promise.race([e.promise,w,S]),e.release(),d=void 0}if(!f()||!c||!a)return;C="Error connecting to cast session",E=!0;let l=c.getCurrentSession();if(!f())return;if(!l){if(await Promise.race([c.requestSession(),w,S]),!f())return;l=c.getCurrentSession()}if(!f())return;if(!l)throw new Error("No active Cast session");v=l,C="Error casting media",await Promise.race([t(a,l,s,e.option.url),w,S]),f()&&(e.notice.show="Casting started",s.onCastStart?.())}catch(y){if(f())throw e.notice.show=C,s.onError?.(y),y}Line truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginChromecast=t(),define(function(){return t.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";var e=(e,t,n)=>new Promise((o,a)=>{var c=e=>{try{i(n.next(e))}catch(t){a(t)}},s=e=>{try{i(n.throw(e))}catch(t){a(t)}},i=e=>e.done?o(e.value):Promise.resolve(e.value).then(c,s);i((n=n.apply(e,t)).next())});return function(t){let n=!1,o=null,a=null;const c=e=>{const t=document.querySelector(".art-icon-cast");if(t)switch(e){case"connected":t.style.color="red";break;case"connecting":case"disconnecting":t.style.color="orange";break;default:t.style.color="white"}},s=()=>new Promise((e,s)=>{var i;window.__onGCastApiAvailable=i=>{if(i){const s=window.cast.framework.CastContext.getInstance();s.setOptions({receiverApplicationId:window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED}),s.addEventListener(window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,e=>{var n,s,i,l,r;const d=window.cast.framework.SessionState;switch(a=e.sessionState,o=e.session,e.sessionState){case d.NO_SESSION:null==(n=t.onStateChange)||n.call(t,"disconnected"),c("disconnected");break;case d.SESSION_STARTING:null==(s=t.onStateChange)||s.call(t,"connecting"),c("connecting");break;case d.SESSION_STARTED:null==(i=t.onStateChange)||i.call(t,"connected"),c("connected");break;case d.SESSION_ENDING:null==(l=t.onStateChange)||l.call(t,"disconnecting"),c("disconnecting");break;case d.SESSION_RESUMED:null==(r=t.onStateChange)||r.call(t,"connected"),c("connected")}}),s.addEventListener(window.cast.framework.CastContextEventType.CAST_STATE_CHANGED,e=>{var n,o,a;const c=window.cast.framework.CastState;switch(e.castState){case c.NO_DEVICES_AVAILABLE:null==(n=t.onCastAvailable)||n.call(t,!1);break;case c.NOT_CONNECTED:null==(o=t.onCastAvailable)||o.call(t,!0);break;case c.CONNECTING:case c.CONNECTED:null==(a=t.onCastAvailable)||a.call(t,!0)}}),n=!0,e()}else s(new Error("Cast API is not available"))},window.chrome&&window.chrome.cast||(i=t.sdk||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",new Promise((e,t)=>{const n=document.createElement("script");n.src=i,n.onload=e,n.onerror=t,document.body.appendChild(n)})).catch(s)}),i=(e,n)=>{const o=t.url||e.option.url,a=new window.chrome.cast.media.MediaInfo(o,t.mimeType||function(e){return{mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"}[e.split("?")[0].split("#")[0].split(".").pop().toLowerCase()]||"application/octet-stream"}(o)),c=new window.chrome.cast.media.LoadRequest(a);n.loadMedia(c).then(()=>{var n;e.notice.show="Casting started",null==(n=t.onCastStart)||n.call(t)}).catch(n=>{var o;throw e.notice.show="Error casting media",null==(o=t.onError)||o.call(t,n),n})};return c=>e(null,null,function*(){return c.controls.add({name:"chromecast",position:"right",tooltip:"Chromecast",html:`<i class="art-icon art-icon-cast">${t.icon||'<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>'}</i>`,click:()=>e(null,null,function*(){var e,a;if(!n)try{yield s()}catch(r){throw c.notice.show="Failed to initialize Cast API",null==(e=t.onError)||e.call(t,r),r}const l=window.cast.framework.CastContext.getInstance();if(o)i(c,o);else try{const e=yield l.requestSession();i(c,e)}catch(r){throw c.notice.show="Error connecting to cast session",null==(a=t.onError)||a.call(t,r),r}})}),{name:"artplayerPluginChromecast",getCastState:()=>a,isCasting:()=>null!==o}})}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginChromecast=t(),define(function(){return e.artplayerPluginChromecast})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginChromecast=t()}(this,function(){"use strict";var e=(e,t,o)=>new Promise((n,i)=>{var r=e=>{try{a(o.next(e))}catch(t){i(t)}},s=e=>{try{a(o.throw(e))}catch(t){i(t)}},a=e=>e.done?n(e.value):Promise.resolve(e.value).then(r,s);a((o=o.apply(e,t)).next())});const t={mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",ogv:"video/ogg",mp3:"audio/mp3",wav:"audio/wav",flv:"video/x-flv",mov:"video/quicktime",avi:"video/x-msvideo",wmv:"video/x-ms-wmv",mpd:"application/dash+xml",m3u8:"application/x-mpegURL"};function o(e,o,n,i){const r=n.url||i,s=r.split("?")[0].split("#")[0].split(".").pop().toLowerCase(),a=new e.media.MediaInfo(r,n.mimeType||t[s]||"application/octet-stream");return o.loadMedia(new e.media.LoadRequest(a))}const n=new WeakMap,i=new WeakSet;function r(e){var t,o,n,i;const r=null==(t=e.cast)?void 0:t.framework,s=null==(o=e.chrome)?void 0:o.cast;if("function"===typeof(null==(n=null==r?void 0:r.CastContext)?void 0:n.getInstance)&&r.CastContextEventType&&r.SessionState&&r.CastState&&"function"===typeof(null==(i=null==s?void 0:s.media)?void 0:i.MediaInfo)&&"function"===typeof s.media.LoadRequest&&s.AutoJoinPolicy)return{framework:r,media:s.media,autoJoinPolicy:s.AutoJoinPolicy.ORIGIN_SCOPED}}function s(e,t){let o;const n=new Promise((n,i)=>{o={ready:t,resolve:n,reject:i},e.subscribers.add(o)});let i=!1;return{promise:n,release:()=>{i||(i=!0,e.subscribers.delete(o),o.reject(new Error("Cast initialization cancelled")),e.subscribers.size||e.cancel())}}}function a(t,a){let l,c,u,d,v,f,m=!1,S=null,p=null,E=0,C=!1;const w=new Promise(e=>{f=()=>e(void 0)});let h;const y=()=>!m&&!t.isDestroy,g=e=>{var t;if(null==(t=a.onStateChange)||t.call(a,e),!y())return;const o=null==h?void 0:h.querySelector(".art-icon-cast");o&&(o.style.color="connected"===e?"red":"connecting"===e||"disconnecting"===e?"orange":"white")},A=e=>{if(!y()||!l)return;p=e.sessionState;const t=l.framework.SessionState;p===t.NO_SESSION||p===t.SESSION_ENDED||p===t.SESSION_START_FAILED?((p!==t.NO_SESSION||C)&&(E++,null==v||v()),S=null,g("disconnected")):((p===t.SESSION_ENDING||S&&e.session&&e.session!==S)&&(E++,null==v||v()),S=e.session||null,p===t.SESSION_STARTING?g("connecting"):p===t.SESSION_ENDING?g("disconnecting"):p!==t.SESSION_STARTED&&p!==t.SESSION_RESUMED||g("connected"))},_=e=>{var t,o;if(!y()||!l)return;const n=l.framework.CastState;e.castState===n.NO_DEVICES_AVAILABLE?null==(t=a.onCastAvailable)||t.call(a,!1):e.castState!==n.NOT_CONNECTED&&e.castState!==n.CONNECTING&&e.castState!==n.CONNECTED||null==(o=a.onCastAvailable)||o.call(a,!0)},N=()=>{const e=c,t=l;if(c=void 0,l=void 0,e&&t){const n=t.framework.CastContextEventType,i=[];try{e.removeEventListener(n.SESSION_STATE_CHANGED,A)}catch(o){i.push(o)}try{e.removeEventListener(n.CAST_STATE_CHANGED,_)}catch(o){i.push(o)}if(i.length)throw i[0]}},I=()=>{if(m)return;m=!0,f();const e=d;d=void 0;const o=[];for(const i of[e,N,()=>t.off("destroy",I)])try{null==i||i()}catch(n){o.push(n)}if(S=null,h=void 0,o.length)throw o[0]};function T(){return e(this,null,function*(){var e,u;const f=E,m=()=>y()&&E===f;let p;const h=new Promise(e=>{p=()=>e(void 0)});v=p;let g="Failed to initialize Cast API";try{if(!l||!c){const e=function(e,t){const o=window,i=r(o);if(i)return{promise:new Promise((e,o)=>{try{t(i),e(i)}catch(n){o(n)}}),release:()=>{}};const a=n.get(o);if(a)return s(a,t);const l={subscribers:new Set,cancel:()=>m(void 0,new Error("Cast initialization cancelled"))};n.set(o,l);const c=s(l,t),u=o.__onGCastApiAvailable;let d,v,f=!1;function m(e,t){if(!f){f=!0,void 0!==v&&o.clearTimeout(v),n.delete(o),o.__onGCastApiAvailable===S&&(o.__onGCastApiAvailable=u),d&&(d.onload=null,d.onerror=null,e||d.remove());for(const o of l.subscribers)try{e?(o.ready(e),o.resolve(e)):o.reject(t)}catch(i){o.reject(i)}l.subscribers.clear()}}function S(e,t){try{null==u||u.call(o,e,t)}finally{m(e?r(o):void 0,new Error("Cast API is not available"))}}o.__onGCastApiAvailable=S;try{v=o.setTimeout(()=>m(void 0,new Error("Timed out initializing Cast API")),3e4),d=document.createElement("script"),d.src=e||"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1",d.onerror=e=>m(void 0,e),d.onload=()=>{const e=r(o);e&&m(e)},document.body.appendChild(d)}catch(p){m(void 0,p)}return c}(a.sdk,e=>{if(!m())return;if(l=e,c=function(e){const t=e.framework.CastContext.getInstance();return i.has(t)||(t.setOptions({receiverApplicationId:e.media.DEFAULT_MEDIA_RECEIVER_APP_ID,autoJoinPolicy:e.autoJoinPolicy}),i.add(t)),t}(l),!m())return;const t=l.framework.CastContextEventType;try{c.addEventListener(t.SESSION_STATE_CHANGED,A),y()&&c.addEventListener(t.CAST_STATE_CHANGED,_)}catch(o){try{N()}catch(n){}throw o}});d=e.release,yield Promise.race([e.promise,w,h]),e.relLine truncated
@@ -4,173 +4,359 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
}
function getMimeType(url) {
const icon = '<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h17v14h-8M3 11a10 10 0 0 1 10 10M3 16a5 5 0 0 1 5 5"/><circle cx="3" cy="21" r="1" fill="currentColor" stroke="none"/></svg>\r\n';
const mimeTypes = {
mp4: "video/mp4",
webm: "video/webm",
ogg: "video/ogg",
ogv: "video/ogg",
mp3: "audio/mp3",
wav: "audio/wav",
flv: "video/x-flv",
mov: "video/quicktime",
avi: "video/x-msvideo",
wmv: "video/x-ms-wmv",
mpd: "application/dash+xml",
m3u8: "application/x-mpegURL"
};
function loadMedia(sdk, session, option, currentUrl) {
const url = option.url || currentUrl;
const extension = url.split("?")[0].split("#")[0].split(".").pop().toLowerCase();
const mimeTypes = {
mp4: "video/mp4",
webm: "video/webm",
ogg: "video/ogg",
ogv: "video/ogg",
mp3: "audio/mp3",
wav: "audio/wav",
flv: "video/x-flv",
mov: "video/quicktime",
avi: "video/x-msvideo",
wmv: "video/x-ms-wmv",
mpd: "application/dash+xml",
m3u8: "application/x-mpegURL"
};
return mimeTypes[extension] || "application/octet-stream";
const info = new sdk.media.MediaInfo(url, option.mimeType || mimeTypes[extension] || "application/octet-stream");
return session.loadMedia(new sdk.media.LoadRequest(info));
}
function artplayerPluginChromecast(option) {
const DEFAULT_ICON = `<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>`;
const DEFAULT_SDK = "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
let isCastInitialized = false;
let castSession = null;
let castState = null;
const updateCastButton = (state) => {
const button = document.querySelector(".art-icon-cast");
if (button) {
switch (state) {
case "connected":
button.style.color = "red";
break;
case "connecting":
case "disconnecting":
button.style.color = "orange";
break;
case "disconnected":
default:
button.style.color = "white";
break;
const DEFAULT_SDK = "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
const pending = /* @__PURE__ */ new WeakMap();
const configured = /* @__PURE__ */ new WeakSet();
function ready(host) {
const framework = host.cast?.framework;
const api = host.chrome?.cast;
if (typeof framework?.CastContext?.getInstance === "function" && framework.CastContextEventType && framework.SessionState && framework.CastState && typeof api?.media?.MediaInfo === "function" && typeof api.media.LoadRequest === "function" && api.AutoJoinPolicy) {
return { framework, media: api.media, autoJoinPolicy: api.AutoJoinPolicy.ORIGIN_SCOPED };
}
}
function configure(sdk) {
const context = sdk.framework.CastContext.getInstance();
if (!configured.has(context)) {
context.setOptions({ receiverApplicationId: sdk.media.DEFAULT_MEDIA_RECEIVER_APP_ID, autoJoinPolicy: sdk.autoJoinPolicy });
configured.add(context);
}
return context;
}
function lease(loading, onReady) {
let subscriber;
const promise = new Promise((resolve, reject) => {
subscriber = { ready: onReady, resolve, reject };
loading.subscribers.add(subscriber);
});
let released = false;
return { promise, release: () => {
if (released)
return;
released = true;
loading.subscribers.delete(subscriber);
subscriber.reject(new Error("Cast initialization cancelled"));
if (!loading.subscribers.size)
loading.cancel();
} };
}
function loadSdk(src, onReady) {
const host = window;
const loaded = ready(host);
if (loaded) {
const promise = new Promise((resolve, reject) => {
try {
onReady(loaded);
resolve(loaded);
} catch (error) {
reject(error);
}
});
return { promise, release: () => {
} };
}
const existing = pending.get(host);
if (existing)
return lease(existing, onReady);
const loading = { subscribers: /* @__PURE__ */ new Set(), cancel: () => finish(void 0, new Error("Cast initialization cancelled")) };
pending.set(host, loading);
const subscription = lease(loading, onReady);
const previous = host.__onGCastApiAvailable;
let script;
let timer;
let settled = false;
function finish(sdk, error) {
if (settled)
return;
settled = true;
if (timer !== void 0)
host.clearTimeout(timer);
pending.delete(host);
if (host.__onGCastApiAvailable === available)
host.__onGCastApiAvailable = previous;
if (script) {
script.onload = null;
script.onerror = null;
if (!sdk)
script.remove();
}
for (const subscriber of loading.subscribers) {
try {
if (sdk) {
subscriber.ready(sdk);
subscriber.resolve(sdk);
} else {
subscriber.reject(error);
}
} catch (error2) {
subscriber.reject(error2);
}
}
};
const initializeCastApi = () => {
return new Promise((resolve, reject) => {
window.__onGCastApiAvailable = (isAvailable) => {
if (isAvailable) {
const context = window.cast.framework.CastContext.getInstance();
context.setOptions({
receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
});
context.addEventListener(
window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(event) => {
const SessionState = window.cast.framework.SessionState;
castState = event.sessionState;
castSession = event.session;
switch (event.sessionState) {
case SessionState.NO_SESSION:
option.onStateChange?.("disconnected");
updateCastButton("disconnected");
break;
case SessionState.SESSION_STARTING:
option.onStateChange?.("connecting");
updateCastButton("connecting");
break;
case SessionState.SESSION_STARTED:
option.onStateChange?.("connected");
updateCastButton("connected");
break;
case SessionState.SESSION_ENDING:
option.onStateChange?.("disconnecting");
updateCastButton("disconnecting");
break;
case SessionState.SESSION_RESUMED:
option.onStateChange?.("connected");
updateCastButton("connected");
break;
}
}
);
context.addEventListener(window.cast.framework.CastContextEventType.CAST_STATE_CHANGED, (event) => {
const CastState = window.cast.framework.CastState;
switch (event.castState) {
case CastState.NO_DEVICES_AVAILABLE:
option.onCastAvailable?.(false);
break;
case CastState.NOT_CONNECTED:
option.onCastAvailable?.(true);
break;
case CastState.CONNECTING:
case CastState.CONNECTED:
option.onCastAvailable?.(true);
break;
}
});
isCastInitialized = true;
resolve();
} else {
reject(new Error("Cast API is not available"));
}
};
if (!window.chrome || !window.chrome.cast) {
loadScript(option.sdk || DEFAULT_SDK).catch(reject);
}
});
};
const castVideo = (art, session) => {
const url = option.url || art.option.url;
const mediaInfo = new window.chrome.cast.media.MediaInfo(url, option.mimeType || getMimeType(url));
const request = new window.chrome.cast.media.LoadRequest(mediaInfo);
session.loadMedia(request).then(() => {
art.notice.show = "Casting started";
option.onCastStart?.();
}).catch((error) => {
art.notice.show = "Error casting media";
option.onError?.(error);
throw error;
});
};
return async (art) => {
art.controls.add({
name: "chromecast",
position: "right",
tooltip: "Chromecast",
html: `<i class="art-icon art-icon-cast">${option.icon || DEFAULT_ICON}</i>`,
click: async () => {
if (!isCastInitialized) {
try {
await initializeCastApi();
} catch (error) {
art.notice.show = "Failed to initialize Cast API";
option.onError?.(error);
throw error;
}
}
const context = window.cast.framework.CastContext.getInstance();
if (castSession) {
castVideo(art, castSession);
} else {
try {
const session = await context.requestSession();
castVideo(art, session);
} catch (error) {
art.notice.show = "Error connecting to cast session";
option.onError?.(error);
throw error;
}
}
}
});
return {
name: "artplayerPluginChromecast",
getCastState: () => castState,
isCasting: () => castSession !== null
loading.subscribers.clear();
}
function available(value, errorInfo) {
try {
previous?.call(host, value, errorInfo);
} finally {
const sdk = value ? ready(host) : void 0;
finish(sdk, new Error("Cast API is not available"));
}
}
host.__onGCastApiAvailable = available;
try {
timer = host.setTimeout(() => finish(void 0, new Error("Timed out initializing Cast API")), 3e4);
script = document.createElement("script");
script.src = src || DEFAULT_SDK;
script.onerror = (error) => finish(void 0, error);
script.onload = () => {
const sdk = ready(host);
if (sdk)
finish(sdk);
};
};
document.body.appendChild(script);
} catch (error) {
finish(void 0, error);
}
return subscription;
}
function createController(art, option) {
let disposed = false;
let sdk;
let context;
let session = null;
let state = null;
let activeClick;
let releaseLoader;
let generation = 0;
let operationStarted = false;
let cancelOperation;
let cancel;
const cancelled = new Promise((resolve) => {
cancel = () => resolve(void 0);
});
let element;
const active = () => !disposed && !art.isDestroy;
const update = (value) => {
option.onStateChange?.(value);
if (!active())
return;
const button = element?.querySelector(".art-icon-cast");
if (button)
button.style.color = value === "connected" ? "red" : value === "connecting" || value === "disconnecting" ? "orange" : "white";
};
const onSession = (event) => {
if (!active() || !sdk)
return;
state = event.sessionState;
const states = sdk.framework.SessionState;
if (state === states.NO_SESSION || state === states.SESSION_ENDED || state === states.SESSION_START_FAILED) {
if (state !== states.NO_SESSION || operationStarted) {
generation++;
cancelOperation?.();
}
session = null;
update("disconnected");
} else {
if (state === states.SESSION_ENDING || session && event.session && event.session !== session) {
generation++;
cancelOperation?.();
}
session = event.session || null;
if (state === states.SESSION_STARTING)
update("connecting");
else if (state === states.SESSION_ENDING)
update("disconnecting");
else if (state === states.SESSION_STARTED || state === states.SESSION_RESUMED)
update("connected");
}
};
const onAvailability = (event) => {
if (!active() || !sdk)
return;
const states = sdk.framework.CastState;
if (event.castState === states.NO_DEVICES_AVAILABLE)
option.onCastAvailable?.(false);
else if (event.castState === states.NOT_CONNECTED || event.castState === states.CONNECTING || event.castState === states.CONNECTED)
option.onCastAvailable?.(true);
};
const detach = () => {
const ownedContext = context;
const ownedSdk = sdk;
context = void 0;
sdk = void 0;
if (ownedContext && ownedSdk) {
const events = ownedSdk.framework.CastContextEventType;
const errors = [];
try {
ownedContext.removeEventListener(events.SESSION_STATE_CHANGED, onSession);
} catch (error) {
errors.push(error);
}
try {
ownedContext.removeEventListener(events.CAST_STATE_CHANGED, onAvailability);
} catch (error) {
errors.push(error);
}
if (errors.length)
throw errors[0];
}
};
const destroy = () => {
if (disposed)
return;
disposed = true;
cancel();
const release = releaseLoader;
releaseLoader = void 0;
const errors = [];
for (const cleanup of [release, detach, () => art.off("destroy", destroy)]) {
try {
cleanup?.();
} catch (error) {
errors.push(error);
}
}
session = null;
element = void 0;
if (errors.length)
throw errors[0];
};
async function cast() {
const epoch = generation;
const currentOperation = () => active() && generation === epoch;
let stop;
const superseded = new Promise((resolve) => {
stop = () => resolve(void 0);
});
cancelOperation = stop;
let failureNotice = "Failed to initialize Cast API";
try {
if (!sdk || !context) {
const loading = loadSdk(option.sdk, (available) => {
if (!currentOperation())
return;
sdk = available;
context = configure(sdk);
if (!currentOperation())
return;
const events = sdk.framework.CastContextEventType;
try {
context.addEventListener(events.SESSION_STATE_CHANGED, onSession);
if (active())
context.addEventListener(events.CAST_STATE_CHANGED, onAvailability);
} catch (error) {
try {
detach();
} catch {
}
throw error;
}
});
releaseLoader = loading.release;
await Promise.race([loading.promise, cancelled, superseded]);
loading.release();
releaseLoader = void 0;
}
if (!currentOperation() || !context || !sdk)
return;
failureNotice = "Error connecting to cast session";
operationStarted = true;
let current = context.getCurrentSession();
if (!currentOperation())
return;
if (!current) {
await Promise.race([context.requestSession(), cancelled, superseded]);
if (!currentOperation())
return;
current = context.getCurrentSession();
}
if (!currentOperation())
return;
if (!current)
throw new Error("No active Cast session");
session = current;
failureNotice = "Error casting media";
await Promise.race([loadMedia(sdk, current, option, art.option.url), cancelled, superseded]);
if (currentOperation()) {
art.notice.show = "Casting started";
option.onCastStart?.();
}
} catch (error) {
if (currentOperation()) {
art.notice.show = failureNotice;
option.onError?.(error);
throw error;
}
} finally {
releaseLoader?.();
releaseLoader = void 0;
if (cancelOperation === stop)
cancelOperation = void 0;
operationStarted = false;
}
}
const click = () => {
if (!active())
return Promise.resolve();
if (!activeClick) {
let resolve;
let reject;
activeClick = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
activeClick.catch(() => {
});
cast().then(() => {
activeClick = void 0;
resolve();
}, (error) => {
activeClick = void 0;
reject(error);
});
}
return activeClick;
};
art.controls.add({
name: "chromecast",
position: "right",
tooltip: "Chromecast",
html: `<i class="art-icon art-icon-cast">${option.icon || icon}</i>`,
mounted(control) {
if (active())
element = control;
},
click
});
art.on("destroy", destroy);
if (art.isDestroy)
destroy();
return { name: "artplayerPluginChromecast", getCastState: () => state, isCasting: () => session !== null };
}
function artplayerPluginChromecast(option) {
return async (art) => createController(art, option);
}
Object.defineProperty(artplayerPluginChromecast, "default", { value: artplayerPluginChromecast, writable: true, configurable: true });
export {
artplayerPluginChromecast as default
};
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>
<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h17v14h-8M3 11a10 10 0 0 1 10 10M3 16a5 5 0 0 1 5 5"/><circle cx="3" cy="21" r="1" fill="currentColor" stroke="none"/></svg>

Before

Width:  |  Height:  |  Size: 691 B

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1,233 @@
import type Artplayer from 'artplayer'
import type { AvailabilityEvent, CastContext, CastSdk, CastSession, ChromecastOptions, ConnectionState, SessionEvent } from './types'
import icon from './cast.svg?raw'
import { loadMedia } from './media'
import { configure, loadSdk } from './sdk'
export function createController(art: Artplayer, option: ChromecastOptions) {
let disposed = false
let sdk: CastSdk | undefined
let context: CastContext | undefined
let session: CastSession | null = null
let state: string | null = null
let activeClick: Promise<void> | undefined
let releaseLoader: (() => void) | undefined
let generation = 0
let operationStarted = false
let cancelOperation: (() => void) | undefined
let cancel!: () => void
const cancelled = new Promise<undefined>((resolve) => {
cancel = () => resolve(undefined)
})
let element: HTMLElement | undefined
const active = () => !disposed && !art.isDestroy
const update = (value: ConnectionState) => {
option.onStateChange?.(value)
if (!active())
return
const button = element?.querySelector<HTMLElement>('.art-icon-cast')
if (button)
button.style.color = value === 'connected' ? 'red' : value === 'connecting' || value === 'disconnecting' ? 'orange' : 'white'
}
const onSession = (event: SessionEvent) => {
if (!active() || !sdk)
return
state = event.sessionState
const states = sdk.framework.SessionState
if (state === states.NO_SESSION || state === states.SESSION_ENDED || state === states.SESSION_START_FAILED) {
if (state !== states.NO_SESSION || operationStarted) {
generation++
cancelOperation?.()
}
session = null
update('disconnected')
}
else {
if (state === states.SESSION_ENDING || (session && event.session && event.session !== session)) {
generation++
cancelOperation?.()
}
session = event.session || null
if (state === states.SESSION_STARTING)
update('connecting')
else if (state === states.SESSION_ENDING)
update('disconnecting')
else if (state === states.SESSION_STARTED || state === states.SESSION_RESUMED)
update('connected')
}
}
const onAvailability = (event: AvailabilityEvent) => {
if (!active() || !sdk)
return
const states = sdk.framework.CastState
if (event.castState === states.NO_DEVICES_AVAILABLE)
option.onCastAvailable?.(false)
else if (event.castState === states.NOT_CONNECTED || event.castState === states.CONNECTING || event.castState === states.CONNECTED)
option.onCastAvailable?.(true)
}
const detach = () => {
const ownedContext = context
const ownedSdk = sdk
context = undefined
sdk = undefined
if (ownedContext && ownedSdk) {
const events = ownedSdk.framework.CastContextEventType
const errors: unknown[] = []
try {
ownedContext.removeEventListener(events.SESSION_STATE_CHANGED, onSession)
}
catch (error) {
errors.push(error)
}
try {
ownedContext.removeEventListener(events.CAST_STATE_CHANGED, onAvailability)
}
catch (error) {
errors.push(error)
}
if (errors.length)
throw errors[0]
}
}
const destroy = () => {
if (disposed)
return
disposed = true
cancel()
const release = releaseLoader
releaseLoader = undefined
const errors: unknown[] = []
for (const cleanup of [release, detach, () => art.off('destroy', destroy)]) {
try {
cleanup?.()
}
catch (error) {
errors.push(error)
}
}
session = null
// ArtPlayer owns removal of its controls at player destruction.
element = undefined
if (errors.length)
throw errors[0]
}
async function cast() {
const epoch = generation
const currentOperation = () => active() && generation === epoch
let stop!: () => void
const superseded = new Promise<undefined>((resolve) => {
stop = () => resolve(undefined)
})
cancelOperation = stop
let failureNotice = 'Failed to initialize Cast API'
try {
if (!sdk || !context) {
const loading = loadSdk(option.sdk, (available) => {
if (!currentOperation())
return
sdk = available
context = configure(sdk)
if (!currentOperation())
return
const events = sdk.framework.CastContextEventType
try {
context.addEventListener(events.SESSION_STATE_CHANGED, onSession)
if (active())
context.addEventListener(events.CAST_STATE_CHANGED, onAvailability)
}
catch (error) {
try {
detach()
}
catch {
// Preserve the setup error; detach already tried both subscriptions.
}
throw error
}
})
releaseLoader = loading.release
await Promise.race([loading.promise, cancelled, superseded])
loading.release()
releaseLoader = undefined
}
if (!currentOperation() || !context || !sdk)
return
failureNotice = 'Error connecting to cast session'
operationStarted = true
let current = context.getCurrentSession()
if (!currentOperation())
return
if (!current) {
await Promise.race([context.requestSession(), cancelled, superseded])
if (!currentOperation())
return
current = context.getCurrentSession()
}
if (!currentOperation())
return
if (!current)
throw new Error('No active Cast session')
session = current
failureNotice = 'Error casting media'
await Promise.race([loadMedia(sdk, current, option, art.option.url), cancelled, superseded])
if (currentOperation()) {
art.notice.show = 'Casting started'
option.onCastStart?.()
}
}
catch (error) {
if (currentOperation()) {
art.notice.show = failureNotice
option.onError?.(error)
throw error
}
}
finally {
releaseLoader?.()
releaseLoader = undefined
if (cancelOperation === stop)
cancelOperation = undefined
operationStarted = false
}
}
const click = () => {
if (!active())
return Promise.resolve()
if (!activeClick) {
let resolve!: () => void
let reject!: (error: unknown) => void
activeClick = new Promise<void>((yes, no) => {
resolve = yes
reject = no
})
// ArtPlayer's DOM dispatcher ignores click results. Observe rejection there
// while retaining the original rejecting Promise for programmatic callers.
activeClick.catch(() => {})
// Publish the guard before SDK setup can synchronously replay an event and
// reenter click through a user's onStateChange callback.
cast().then(() => {
activeClick = undefined
resolve()
}, (error) => {
activeClick = undefined
reject(error)
})
}
return activeClick
}
art.controls.add({
name: 'chromecast',
position: 'right',
tooltip: 'Chromecast',
html: `<i class="art-icon art-icon-cast">${option.icon || icon}</i>`,
mounted(control) {
if (active())
element = control
},
click,
})
art.on('destroy', destroy)
if (art.isDestroy)
destroy()
return { name: 'artplayerPluginChromecast', getCastState: () => state, isCasting: () => session !== null }
}
@@ -1,189 +0,0 @@
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = src
script.onload = resolve
script.onerror = reject
document.body.appendChild(script)
})
}
function getMimeType(url) {
const extension = url.split('?')[0].split('#')[0].split('.').pop().toLowerCase()
const mimeTypes = {
mp4: 'video/mp4',
webm: 'video/webm',
ogg: 'video/ogg',
ogv: 'video/ogg',
mp3: 'audio/mp3',
wav: 'audio/wav',
flv: 'video/x-flv',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
wmv: 'video/x-ms-wmv',
mpd: 'application/dash+xml',
m3u8: 'application/x-mpegURL',
}
return mimeTypes[extension] || 'application/octet-stream'
}
export default function artplayerPluginChromecast(option) {
const DEFAULT_ICON = `<svg height="20" width="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M512 96H64v99c-13-2-26.4-3-40-3H0V96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H288V456c0-13.6-1-27-3-40H512V96zM24 224c128.1 0 232 103.9 232 232c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-101.6-82.4-184-184-184c-13.3 0-24-10.7-24-24s10.7-24 24-24zm8 192a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM0 344c0-13.3 10.7-24 24-24c75.1 0 136 60.9 136 136c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-48.6-39.4-88-88-88c-13.3 0-24-10.7-24-24z"/></svg>`
const DEFAULT_SDK = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1'
let isCastInitialized = false
let castSession = null
let castState = null
const updateCastButton = (state) => {
const button = document.querySelector('.art-icon-cast')
if (button) {
switch (state) {
case 'connected':
button.style.color = 'red'
break
case 'connecting':
case 'disconnecting':
button.style.color = 'orange'
break
case 'disconnected':
default:
button.style.color = 'white'
break
}
}
}
const initializeCastApi = () => {
return new Promise((resolve, reject) => {
window.__onGCastApiAvailable = (isAvailable) => {
if (isAvailable) {
const context = window.cast.framework.CastContext.getInstance()
context.setOptions({
receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
})
// Listen for session state changes
context.addEventListener(
window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(event) => {
const SessionState = window.cast.framework.SessionState
castState = event.sessionState
castSession = event.session
switch (event.sessionState) {
case SessionState.NO_SESSION:
option.onStateChange?.('disconnected')
updateCastButton('disconnected')
break
case SessionState.SESSION_STARTING:
option.onStateChange?.('connecting')
updateCastButton('connecting')
break
case SessionState.SESSION_STARTED:
option.onStateChange?.('connected')
updateCastButton('connected')
break
case SessionState.SESSION_ENDING:
option.onStateChange?.('disconnecting')
updateCastButton('disconnecting')
break
case SessionState.SESSION_RESUMED:
option.onStateChange?.('connected')
updateCastButton('connected')
break
}
},
)
// Listen for cast state changes
context.addEventListener(window.cast.framework.CastContextEventType.CAST_STATE_CHANGED, (event) => {
const CastState = window.cast.framework.CastState
switch (event.castState) {
case CastState.NO_DEVICES_AVAILABLE:
option.onCastAvailable?.(false)
break
case CastState.NOT_CONNECTED:
option.onCastAvailable?.(true)
break
case CastState.CONNECTING:
case CastState.CONNECTED:
option.onCastAvailable?.(true)
break
}
})
isCastInitialized = true
resolve()
}
else {
reject(new Error('Cast API is not available'))
}
}
if (!window.chrome || !window.chrome.cast) {
loadScript(option.sdk || DEFAULT_SDK).catch(reject)
}
})
}
const castVideo = (art, session) => {
const url = option.url || art.option.url
const mediaInfo = new window.chrome.cast.media.MediaInfo(url, option.mimeType || getMimeType(url))
const request = new window.chrome.cast.media.LoadRequest(mediaInfo)
session
.loadMedia(request)
.then(() => {
art.notice.show = 'Casting started'
option.onCastStart?.()
})
.catch((error) => {
art.notice.show = 'Error casting media'
option.onError?.(error)
throw error
})
}
return async (art) => {
art.controls.add({
name: 'chromecast',
position: 'right',
tooltip: 'Chromecast',
html: `<i class="art-icon art-icon-cast">${option.icon || DEFAULT_ICON}</i>`,
click: async () => {
if (!isCastInitialized) {
try {
await initializeCastApi()
}
catch (error) {
art.notice.show = 'Failed to initialize Cast API'
option.onError?.(error)
throw error
}
}
const context = window.cast.framework.CastContext.getInstance()
if (castSession) {
castVideo(art, castSession)
}
else {
try {
const session = await context.requestSession()
castVideo(art, session)
}
catch (error) {
art.notice.show = 'Error connecting to cast session'
option.onError?.(error)
throw error
}
}
},
})
return {
name: 'artplayerPluginChromecast',
getCastState: () => castState,
isCasting: () => castSession !== null,
}
}
}
@@ -0,0 +1,10 @@
import type Artplayer from 'artplayer'
import type { ChromecastOptions } from './types'
import { createController } from './controller'
export default function artplayerPluginChromecast(option: ChromecastOptions) {
return async (art: Artplayer) => createController(art, option)
}
// The 1.0 CommonJS namespace exposed .default; the current root exports a function.
Object.defineProperty(artplayerPluginChromecast, 'default', { value: artplayerPluginChromecast, writable: true, configurable: true })
@@ -0,0 +1,23 @@
import type { CastSdk, CastSession, ChromecastOptions } from './types'
const mimeTypes: Record<string, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
ogg: 'video/ogg',
ogv: 'video/ogg',
mp3: 'audio/mp3',
wav: 'audio/wav',
flv: 'video/x-flv',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
wmv: 'video/x-ms-wmv',
mpd: 'application/dash+xml',
m3u8: 'application/x-mpegURL',
}
export function loadMedia(sdk: CastSdk, session: CastSession, option: ChromecastOptions, currentUrl: string): PromiseLike<unknown> {
const url = option.url || currentUrl
const extension = url.split('?')[0]!.split('#')[0]!.split('.').pop()!.toLowerCase()
const info = new sdk.media.MediaInfo(url, option.mimeType || mimeTypes[extension] || 'application/octet-stream')
return session.loadMedia(new sdk.media.LoadRequest(info))
}
@@ -0,0 +1,130 @@
import type { CastContext, CastSdk, CastWindow } from './types'
const DEFAULT_SDK = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1'
interface Subscriber { ready: (sdk: CastSdk) => void, resolve: (sdk: CastSdk) => void, reject: (error: unknown) => void }
interface Loading { subscribers: Set<Subscriber>, cancel: () => void }
const pending = new WeakMap<CastWindow, Loading>()
const configured = new WeakSet<CastContext>()
function ready(host: CastWindow): CastSdk | undefined {
const framework = host.cast?.framework
const api = host.chrome?.cast
if (typeof framework?.CastContext?.getInstance === 'function'
&& framework.CastContextEventType && framework.SessionState && framework.CastState
&& typeof api?.media?.MediaInfo === 'function' && typeof api.media.LoadRequest === 'function'
&& api.AutoJoinPolicy) { return { framework, media: api.media, autoJoinPolicy: api.AutoJoinPolicy.ORIGIN_SCOPED } }
}
export function configure(sdk: CastSdk): CastContext {
const context = sdk.framework.CastContext.getInstance()
if (!configured.has(context)) {
context.setOptions({ receiverApplicationId: sdk.media.DEFAULT_MEDIA_RECEIVER_APP_ID, autoJoinPolicy: sdk.autoJoinPolicy })
configured.add(context)
}
return context
}
function lease(loading: Loading, onReady: (sdk: CastSdk) => void) {
let subscriber!: Subscriber
const promise = new Promise<CastSdk>((resolve, reject) => {
subscriber = { ready: onReady, resolve, reject }
loading.subscribers.add(subscriber)
})
let released = false
return { promise, release: () => {
if (released)
return
released = true
loading.subscribers.delete(subscriber)
subscriber.reject(new Error('Cast initialization cancelled'))
if (!loading.subscribers.size)
loading.cancel()
} }
}
export function loadSdk(src: string | undefined, onReady: (sdk: CastSdk) => void) {
const host = window as CastWindow
const loaded = ready(host)
if (loaded) {
const promise = new Promise<CastSdk>((resolve, reject) => {
try {
onReady(loaded)
resolve(loaded)
}
catch (error) {
reject(error)
}
})
return { promise, release: () => {} }
}
const existing = pending.get(host)
if (existing)
return lease(existing, onReady)
const loading: Loading = { subscribers: new Set(), cancel: () => finish(undefined, new Error('Cast initialization cancelled')) }
pending.set(host, loading)
const subscription = lease(loading, onReady)
const previous = host.__onGCastApiAvailable
let script: HTMLScriptElement | undefined
let timer: number | undefined
let settled = false
function finish(sdk?: CastSdk, error?: unknown) {
if (settled)
return
settled = true
if (timer !== undefined)
host.clearTimeout(timer)
pending.delete(host)
if (host.__onGCastApiAvailable === available)
host.__onGCastApiAvailable = previous
if (script) {
script.onload = null
script.onerror = null
if (!sdk)
script.remove()
}
for (const subscriber of loading.subscribers) {
try {
if (sdk) {
subscriber.ready(sdk)
subscriber.resolve(sdk)
}
else {
subscriber.reject(error)
}
}
catch (error) {
subscriber.reject(error)
}
}
loading.subscribers.clear()
}
function available(value: boolean, errorInfo?: string) {
// The page's existing consumer keeps its callback and its exception semantics.
// Always settle our own waiters even if that consumer throws.
try {
previous?.call(host, value, errorInfo)
}
finally {
const sdk = value ? ready(host) : undefined
finish(sdk, new Error('Cast API is not available'))
}
}
host.__onGCastApiAvailable = available
try {
timer = host.setTimeout(() => finish(undefined, new Error('Timed out initializing Cast API')), 30000)
script = document.createElement('script')
script.src = src || DEFAULT_SDK
script.onerror = error => finish(undefined, error)
script.onload = () => {
const sdk = ready(host)
if (sdk)
finish(sdk)
}
document.body.appendChild(script)
}
catch (error) {
finish(undefined, error)
}
return subscription
}
@@ -0,0 +1,51 @@
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'disconnecting'
export interface ChromecastOptions {
url?: string
sdk?: string
icon?: string
mimeType?: string
onStateChange?: (this: ChromecastOptions, state: ConnectionState) => void
onCastAvailable?: (this: ChromecastOptions, available: boolean) => void
onCastStart?: (this: ChromecastOptions) => void
onError?: (this: ChromecastOptions, error: unknown) => void
}
export interface MediaInfo {
contentId: string
contentType: string
}
export interface LoadRequest { media: MediaInfo }
export interface CastSession { loadMedia: (request: LoadRequest) => PromiseLike<unknown> }
export interface SessionEvent { sessionState: string, session?: CastSession | null }
export interface AvailabilityEvent { castState: string }
export interface CastContext {
setOptions: (options: { receiverApplicationId: string, autoJoinPolicy: string }) => void
getCurrentSession: () => CastSession | null
requestSession: () => PromiseLike<unknown>
addEventListener: (name: string, listener: (event: SessionEvent & AvailabilityEvent) => void) => void
removeEventListener: (name: string, listener: (event: SessionEvent & AvailabilityEvent) => void) => void
}
export interface CastSdk {
framework: {
CastContext: { getInstance: () => CastContext }
CastContextEventType: { SESSION_STATE_CHANGED: string, CAST_STATE_CHANGED: string }
SessionState: Record<string, string>
CastState: Record<string, string>
}
media: {
DEFAULT_MEDIA_RECEIVER_APP_ID: string
MediaInfo: new (url: string, mimeType: string) => MediaInfo
LoadRequest: new (media: MediaInfo) => LoadRequest
}
autoJoinPolicy: string
}
export interface CastWindow extends Window {
cast?: { framework?: CastSdk['framework'] }
chrome?: { cast?: { media?: CastSdk['media'], AutoJoinPolicy?: { ORIGIN_SCOPED: string } } }
__onGCastApiAvailable?: (available: boolean, errorInfo?: string) => void
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts", "../../types/assets.d.ts"]
}