{"_id":"@afkcodes/timbre-cast","name":"@afkcodes/timbre-cast","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@afkcodes/timbre-cast","version":"0.1.0","description":"First-party Google Cast sender binding, audio-scoped: discovery, sessions, RemoteMediaClient + receiver queue, typed errors — powered by Nitro Modules","main":"./lib/commonjs/index.js","module":"./lib/module/index.js","types":"./lib/typescript/src/index.d.ts","react-native":"src/index","source":"src/index","scripts":{"typecheck":"tsc --noEmit && tsc --noEmit -p plugin/tsconfig.test.json","clean":"git clean -dfX","release":"semantic-release","test":"vitest run","test:watch":"vitest","test:android":"cd ../../apps/example/android && ./gradlew :afkcodes_timbre-cast:testReleaseUnitTest -PreactNativeArchitectures=arm64-v8a","lint:android":"cd ../../apps/example/android && ./gradlew :afkcodes_timbre-cast:lintRelease -PreactNativeArchitectures=arm64-v8a","build":"npm run typecheck && bob build && npm run build:plugin","build:plugin":"tsc -p plugin","codegen":"nitrogen --logLevel=\"debug\" && npm run build","pod":"npm --prefix ../../apps/example run pod"},"keywords":["react-native","nitro-modules","chromecast","google-cast","cast","remote-playback"],"repository":{"type":"git","url":"git+https://github.com/afkcodes/timbre.git"},"author":{"name":"Ashish Kumar"},"license":"MIT","bugs":{"url":"https://github.com/afkcodes/timbre/issues"},"homepage":"https://github.com/afkcodes/timbre#readme","publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"},"devDependencies":{"@jamesacarr/eslint-formatter-github-actions":"^0.2.0","@semantic-release/changelog":"^7.0.0","@semantic-release/git":"^11.0.1","@testing-library/dom":"^10.4.1","@testing-library/react":"^16.3.2","@types/node":"^26.2.0","@types/react":"19.2.18","@types/react-dom":"^19.2.3","conventional-changelog-conventionalcommits":"^10.2.1","expo":"^57.0.11","jsdom":"^30.0.1","nitrogen":"^0.37.0","react":"19.2.8","react-dom":"19.2.8","react-native":"0.87.1","react-native-builder-bob":"^0.43.0","react-native-nitro-modules":"^0.37.0","semantic-release":"^25.0.7","typescript":"^7.0.2","vitest":"^4.1.10"},"peerDependencies":{"expo":">=55.0.0","react":"*","react-native":">=0.82.0","react-native-nitro-modules":"*"},"peerDependenciesMeta":{"expo":{"optional":true}},"eslintConfig":{"root":true,"extends":["@react-native","prettier"],"plugins":["prettier"],"rules":{"prettier/prettier":["warn",{"quoteProps":"consistent","singleQuote":true,"tabWidth":2,"trailingComma":"es5","useTabs":false}]}},"eslintIgnore":["node_modules/","lib/","android/build/","plugin/build/","nitrogen/"],"prettier":{"quoteProps":"consistent","singleQuote":true,"tabWidth":2,"trailingComma":"es5","useTabs":false,"semi":false},"react-native-builder-bob":{"source":"src","output":"lib","targets":["commonjs","module",["typescript",{"project":"tsconfig.build.json"}]]},"gitHead":"5563c3876bb03f73dd5f1e41b1e945a903a7708a","_id":"@afkcodes/timbre-cast@0.1.0","_nodeVersion":"24.12.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-O4Vc5ccL1IvjSOVyO3EnwTnQE9uUwy75SbN0hdId30QnCUuTZS4Ka9eGUQsyHrxl5byI/sLrY81DlfnM0/gSHA==","shasum":"1c4a8725607f91296804b6a7ece15c7d24c9518e","tarball":"https://registry.npmjs.org/@afkcodes/timbre-cast/-/timbre-cast-0.1.0.tgz","fileCount":254,"unpackedSize":1118754,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDQy2Pxfkqv0VPBABjYT/s8c4F2zzsXV+uq/RcTaOj4QAIhAKkuETil4rV26Zms1XtzoleGP+s+EuVbg2AsnstpaDVy"}]},"_npmUser":{"name":"afkashish","email":"ashish.kmr@outlook.com"},"directories":{},"maintainers":[{"name":"afkashish","email":"ashish.kmr@outlook.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/timbre-cast_0.1.0_1787850294523_0.8579633884556852"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-27T17:04:54.258Z","0.1.0":"2026-08-27T17:04:54.794Z","modified":"2026-08-27T17:04:55.130Z"},"maintainers":[{"name":"afkashish","email":"ashish.kmr@outlook.com"}],"description":"First-party Google Cast sender binding, audio-scoped: discovery, sessions, RemoteMediaClient + receiver queue, typed errors — powered by Nitro Modules","homepage":"https://github.com/afkcodes/timbre#readme","keywords":["react-native","nitro-modules","chromecast","google-cast","cast","remote-playback"],"repository":{"type":"git","url":"git+https://github.com/afkcodes/timbre.git"},"author":{"name":"Ashish Kumar"},"bugs":{"url":"https://github.com/afkcodes/timbre/issues"},"license":"MIT","readme":"# @afkcodes/timbre-cast\n\nFirst-party Google Cast **sender** binding for React Native, audio-scoped, built\ndirectly on the official SDKs\n([play-services-cast-framework](https://developers.google.com/cast/docs/android_sender)\non Android, [google-cast-sdk](https://developers.google.com/cast/docs/ios_sender)\non iOS) as a Nitro Kotlin/Swift module. Chromecast on **both** platforms — no\nplatform-split feature.\n\nCasting is a **URL handoff**, not an output route: the sender hands the receiver\na URL and the receiver fetches, decodes and plays it. Your local player goes\nsilent for the session and resumes at the receiver's position when you transfer\nback. This package ships both layers — the binding, and the automatic\nlocal↔remote handoff state machine `wireCastHandoff`, which lives here rather\nthan in `@afkcodes/timbre-media-session` and talks to your player and queue through\nstructural interfaces, so it works with any player\n([ARCHITECTURE §25](../../ARCHITECTURE.md#25-casting-is-a-url-handoff-behind-the-existing-fan-out--and-the-handoff-lives-in-rn-mediacast-not-media-session)).\n\n## Requirements\n\n| | |\n|---|---|\n| iOS runtime floor | **16.0** — the `google-cast-sdk` pod's own floor. Installing this package raises your app's deployment target, and devices on iOS 15 and older will not be able to install it. The Expo plugin bumps `ios.deploymentTarget` for you; bare projects set `platform :ios, '16.0'` in the Podfile |\n| iOS toolchain | **Xcode 26 or newer**. `google-cast-sdk` 4.8.6 ships a *static* `GoogleCast.xcframework` built against the iOS 26.2 SDK, and one of its objects references `UIGlassEffect`. React Native's `-ObjC` linker flag force-loads every object out of every static archive, so that reference cannot be dead-stripped and Xcode 16.x fails at link time on `_OBJC_CLASS_$_UIGlassEffect`. Your app's runtime floor is unaffected |\n| Android | Google Play services. Without it `Cast.initialize()` resolves `'unavailable'` — a typed capability answer, never a crash. Full output-switcher behaviour needs Android 13+ |\n\n## Install\n\n```sh\nnpm install @afkcodes/timbre-cast react-native-nitro-modules\n```\n\n### Expo (prebuild)\n\nThe plugin applies everything below, including the app-ID-specific Bonjour\nstring.\n\n```jsonc\n// app.json\n{\n  \"expo\": {\n    \"plugins\": [\n      [\"@afkcodes/timbre-cast\", {\n        // Omit for the Default Media Receiver (zero-config).\n        \"receiverAppId\": \"ABCD1234\",\n        // Optional custom text for the iOS local-network prompt.\n        \"localNetworkUsageDescription\": \"…\"\n      }]\n    ]\n  }\n}\n```\n\n### Bare React Native\n\n**Android — `AndroidManifest.xml`**, inside `<application>`:\n\n```xml\n<!-- The Cast framework instantiates this provider reflectively. -->\n<meta-data\n  android:name=\"com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME\"\n  android:value=\"com.rnmediacast.RnMediaCastOptionsProvider\" />\n<!-- Optional: your receiver app id (omit for the Default Media Receiver). -->\n<meta-data\n  android:name=\"com.rnmediacast.RECEIVER_APPLICATION_ID\"\n  android:value=\"ABCD1234\" />\n<!-- Enables output-switcher stream transfer (Android 13+ system picker). -->\n<receiver\n  android:name=\"androidx.mediarouter.media.MediaTransferReceiver\"\n  android:exported=\"true\" />\n```\n\n**iOS — `Info.plist`** (replace `CC1AD845` with your receiver app id if you have\none), plus `platform :ios, '16.0'` in the Podfile:\n\n```xml\n<key>NSBonjourServices</key>\n<array>\n  <string>_googlecast._tcp</string>\n  <string>_CC1AD845._googlecast._tcp</string>\n</array>\n<key>NSLocalNetworkUsageDescription</key>\n<string>$(PRODUCT_NAME) uses the local network to discover Cast-enabled\ndevices on your Wi-Fi network.</string>\n```\n\n## Use\n\n```ts\nimport { Cast } from '@afkcodes/timbre-cast'\n\n// Once, early (idempotent).\nconst state = await Cast.initialize()\n\n// Discovery is battery-expensive: scope it to \"picker open\".\nawait Cast.startDiscovery()\nconst [device] = await Cast.getCastDevices()\nif (device !== undefined) await Cast.requestSession(device.id) // resolves when connected\nawait Cast.stopDiscovery()               // AFTER connecting\n\n// Hand the receiver a queue it advances by itself (the phone may sleep).\nawait Cast.queueLoad(\n  tracks.map((t) => ({\n    source: {\n      url: t.streamUrl,\n      mimeType: 'audio/mp3',\n      metadata: { title: t.title, artist: t.artist, artworkUrl: t.artUrl },\n    },\n    preloadTime: 10,\n  })),\n  { startIndex: 2, startPosition: 30 }\n)\n\nCast.addListener('mediaStatus', () => { /* a discontinuity — project, never poll */ })\nCast.addListener('error', (error) => {\n  // 'cast-receiver-fetch': the RECEIVER could not fetch the URL. Its network is\n  // not the phone's network.\n})\n\nawait Cast.endSession({ transferBackToLocal: true })\n```\n\nConnect **after** the picker closes and **before** `stopDiscovery()`; stopping\ndiscovery mid-handshake makes the route vanish under the session. The rule is\nalso enforced natively as a safety net.\n\n## API\n\n| | what it does | notes |\n|---|---|---|\n| `Cast.initialize(options?): Promise<CastConnectionState>` | Idempotent; call once, early | Resolves `'unavailable'` on a Play-services-less Android device, never a crash. Google recommends initializing in `didFinishLaunching`, so post-process-death session resumption on iOS may be missed until the first `initialize()` of a launch |\n| `Cast.getCastState(): CastConnectionState` | `'unavailable' \\| 'idle' \\| 'connecting' \\| 'connected' \\| 'transferring'` | Synchronous |\n| `Cast.startDiscovery()` / `stopDiscovery()` | Scope them to \"picker open\" — discovery is battery-expensive | Stop **after** connecting |\n| `Cast.getCastDevices(): Promise<readonly CastDeviceInfo[]>` | | |\n| `Cast.requestSession(deviceId?)` / `endSession(options?)` | | `endSession({ transferBackToLocal })`. With no id on iOS the call always resolves, because `presentCastDialog` returns `void`; watch the `castState` / `session` events for the outcome |\n| `Cast.load(source, options?)` / `queueLoad(items, options?)` | Hand the receiver a URL, or a queue it advances by itself | |\n| `Cast.queueInsert` / `queueRemove` / `queueReorder` / `queueJumpTo` / `queueSetRepeatMode` / `getQueueItemIds` / `fetchQueueSlice` | Receiver-side queue editing | |\n| `Cast.play()` / `pause()` / `stop()` / `seek(position, resumeState?)` / `getApproximatePosition()` | Receiver transport | |\n| `Cast.setDeviceVolume` / `setDeviceMuted` / `getDeviceVolume` / `setStreamVolume` / `setStreamMuted` | *Device* volume is what users mean; stream volume is app-level | Prefer `setDeviceVolume()` |\n| `Cast.addListener(event, fn): Unsubscribe` | `castState`, `session`, `devices`, `mediaStatus`, `error`, `queueChanged`, `deviceVolume` | `mediaStatus` is a discontinuity broadcast — project position, never poll |\n| `wireCastHandoff(local, options): CastHandoff` | The whole local↔remote state machine | Returns `{ phase, receiverItemIndex, castTo, stopCasting, syncQueue, skipToItem, skipToNext, skipToPrevious, dispose }` |\n| `useCastState()` / `useIsCasting()` | Live connection state as React state | Seeded synchronously from `getCastState()`, so the first paint is right; no polling. `useIsCasting` holds the boolean, so `idle → connecting` does not re-render |\n| `isCastingState(state): boolean` | The one definition of \"casting\", for non-React callers | `'connected'` or `'transferring'`. A `'transferring'` session is a receiver-to-receiver stream transfer: the phone is still not the output, so treating it as \"not casting\" would flicker the UI back to local controls mid-transfer |\n| `canCastMedia(item): CanCastVerdict` | `{ castable: false, reason: 'codec' \\| 'local-file' \\| 'headers' }` | Grey the route out per track instead of failing at load |\n| `<CastButton style? tintColor? />` | The platform's own button as a native view | Hides itself while cast is unavailable |\n| `CastError` | `code` is the thing to branch on | `statusCode` and the receiver's reason string are Android-only |\n\n`wireCastHandoff`'s `options` are `{ snapshot, cast?, onPhaseChange?, onTransfer?,\nonReceiverState?, onItemsSkipped?, onError?, now?, handoffTimeoutMs? }`; the local\nplayer is `{ play, pause, seekTo, skipToIndex, getPosition, isPlaying }`.\n\n## The handoff — `wireCastHandoff`\n\n```\nLOCAL → CONNECTING → HANDOFF_TO_CAST → CAST_ACTIVE → HANDOFF_TO_LOCAL → LOCAL\n                     (pause local · snapshot queue · load receiver queue)\nany → error → typed error + fall back to LOCAL at the last known position\n```\n\nA [worked example](../../docs/recipes/cast.md) wires it to a player and the\nmedia-session channels.\n\n| Contract | Detail |\n|---|---|\n| The JS queue stays the source of truth | The receiver queue is a castable *projection* of it, and every receiver status is reconciled back to a JS index (`onReceiverState.itemIndex`, `receiverItemIndex`). Receiver-side advancement is on, so the queue survives the phone sleeping; the receiver queue dying with the session is fine, because it is rebuilt next time |\n| Position ownership is exclusive | Local owns the clock until the `toCast` transfer, the receiver until `toLocal`. Both transfers are discontinuities carrying `{ position, itemIndex }` |\n| A session that exists *before* wiring is left alone | Auto-casting over a receiver at app launch would be destructive; the next `castTo` reuses it |\n| `stopCasting({ transferBackToLocal: false })` does **not** keep the receiver playing | It disconnects, and the receiver stops anyway — see the ceilings below |\n| A live handoff joins the live edge | Mark live entries with `live: true`. The projection sends live start items with **no start position**: a nonzero `playPosition` against an unseekable live stream wedges the Default Media Receiver in BUFFERING at that offset forever. Nothing is lost, because a live clock is a stream-timeline offset, not a resumable position |\n| A live transfer-back never seeks the local player | The `restoreLocal` contract carries `live` and the wire skips the seek, which mpv would reject. Reopening at the live edge *is* the resume for live audio |\n| Resolve playlist redirects before the handoff | The Default Media Receiver never starts playback for an HLS playlist URL answering with a 302, though the redirect target plays immediately. Resolve the final URL at the same seam where you resolve signed URLs, sender-side |\n| Live casting needs no special hints | `audio/aacp` plays a Shoutcast stream as-is, and `application/x-mpegurl` plays audio-only HLS with TS/AAC segments without any `hlsSegmentFormat` |\n| `reduceCastHandoff` and `projectCastQueue` are exported | The pure state machine and projection, for tests and custom orchestration |\n\n`onError` with `code === 'cast-receiver-fetch'` is where expired signed URLs are\nhandled: refresh them into your snapshot's `url`, then call `handoff.syncQueue()`.\n\n## `<CastButton/>`\n\nA real native view — an `androidx.mediarouter.app.MediaRouteButton` handed to\n`CastButtonFactory.setUpMediaRouteButton` on Android, a `GCKUICastButton` on iOS\n([ARCHITECTURE](../../ARCHITECTURE.md#castbutton-is-a-real-native-view-because-the-switcher-is-unreachable-otherwise)).\n\n| Behaviour | Detail |\n|---|---|\n| Android 13+ opens the **system output switcher** | The same sheet the volume rocker and the media notification open. That wiring is the only thing that honours `setShowSystemOutputSwitcherOnCastIconClick(true)`, which this package's `CastOptions` sets. Below 13, or without `MediaTransferReceiver`, it falls back to the in-app `MediaRouteChooserDialog` |\n| iOS opens the SDK's own device dialog | Discovery starts on the first tap by SDK design, which is what makes the local-network prompt appear when the user asked for devices rather than at launch |\n| It hides itself while cast is unavailable | Before `Cast.initialize()` resolves, and forever on a Play-services-less Android device. That is the Cast Design Checklist's own rule — do not re-implement it |\n| It has no intrinsic size | A Nitro view's shadow node has no measure function, so the button is exactly as big as `style` says and defaults to 40×40. The icon is drawn centred at the platform's own size and never scaled |\n| `tintColor` is honoured on both platforms | iOS sets the button's `tintColor`; Android recolours the drawn icon, because `MediaRouteButton` reads its tint from a theme attribute once at construction. Both keep Google's own icon and its connecting animation |\n| A tap starts an ordinary cast session | A wired `wireCastHandoff` picks it up exactly as if you had called `handoff.castTo(id)` |\n\nThe headless path is fully supported for apps that want their own picker —\n`startDiscovery()` + `getCastDevices()` + `requestSession(id)`, or\n`Cast.requestSession()` for the SDK picker without the view. It just does not get\nthe system output switcher.\n\n## Platform parity\n\nEverything works the same on both platforms **except** the rows below.\n\n| Member | Android | iOS | Why |\n|---|---|---|---|\n| Hardware volume buttons drive receiver volume | the framework routes the keys for a connected session | **no** | Google's own guide says the behaviour is \"currently not supported for iOS 15+\", so this package leaves the switch off rather than shipping one that does nothing. Use `setDeviceVolume()` |\n| `error.statusCode` and the receiver's reason string on a media error | present | **absent** | GoogleCast 4.8.6 has no media-error callback at all, so the iOS half synthesizes the failure from `playerState == .idle && idleReason == .error`. Branch on `error.code`, never on `statusCode` being present |\n| Changing the receiver app id after the first `initialize()` | honoured | logged and ignored | `GCKCastContext` exposes only `+setSharedInstanceWithOptions:`, with no way to swap the live discovery criteria. Pass the id on the first call, or through the Expo plugin |\n| `'transferring'` state, the `transferring` / `transferred` / `transferFailed` session events | from the system output switcher | **never fire** | iOS has no such surface. `startFailed` covers a failed session *start* on both; a failed session *resume* only on Android, because 4.8.6 has no resume-failure callback |\n| Lock-screen controls during a session | drawn | **none** | The phone plays no audio — an OS ceiling shared by every cast app, and Google's own checklist marks lock-screen controls Android-only |\n\n## Ceilings\n\n- **Receiver codec ceiling.** Receivers decode HE-/LC-AAC, MP3, FLAC (≤ 96 kHz /\n  24-bit), Opus, Vorbis, WAV and WebM audio\n  ([reference](https://developers.google.com/cast/docs/media)). Not castable:\n  ALAC, hi-res FLAC above 96 kHz, WMA, APE, WavPack, TTA, DSD, AIFF, `.mka`,\n  AC-3/DTS-as-audio, tracker formats. `canCastMedia()` exists for exactly this.\n- **The receiver fetches the URL itself.** `file://` and `content://` sources\n  cannot cast — there is no local HTTP server in v1. Per-source auth headers do\n  not travel either, because the Default Media Receiver cannot attach them:\n  signed-query URLs work, header auth needs your own Web Receiver plus the\n  `credentials` passthrough on `load`/`queueLoad`.\n- **The iOS local-network prompt appears on the first cast-button use**, never\n  before. That is an OS rule.\n- **Leaving the receiver playing is not possible from a lone sender.**\n  `endSession({ transferBackToLocal: false })` disconnects without resuming\n  locally, but the receiver stops anyway — the iOS SDK documents that\n  `endSessionAndStopCasting:` \"only applies when multiple sender devices are\n  connected\".\n- **Gapless does not survive the handoff.** Receiver queues pre-buffer\n  (`preloadTime`); they do not promise sample-accurate gapless.\n- **Receiver app id.** The zero-config default is Google's Default Media\n  Receiver. A styled or custom receiver needs a [Cast Developer\n  Console](https://cast.google.com/publish) registration, and is the only path to\n  receiver-side header auth.\n- **Volume has two layers**: *device* volume (what users mean) and *stream*\n  volume (app-level). Prefer `setDeviceVolume()`.\n- The [Cast Design Checklist](https://developers.google.com/cast/docs/design_checklist)\n  binds your app: cast icon placement, user-initiated casting only.\n\n## Version pins\n\n| SDK | Version |\n| --- | --- |\n| `com.google.android.gms:play-services-cast-framework` | 22.3.1 |\n| `google-cast-sdk` (CocoaPods) | 4.8.6 |\n\nPinned exactly on purpose — an earlier iOS 4.8.x release broke discovery — and\n`scripts/check-upstream.mjs` watches both rows so a lag is loud, not silent.\n\n## Also exported\n\n| Group | Exports |\n|---|---|\n| Options | `CastInitOptions` — `{ receiverApplicationId? }`; `CastLoadOptions` — `{ autoplay?, startPosition?, playbackRate? }`; `CastQueueLoadOptions` — `{ startIndex?, startPosition?, repeatMode?, credentials?, credentialsType? }`; `EndSessionOptions`; `CastRepeatMode` |\n| Media | `CastMediaMetadata` — `{ title?, artist?, albumTitle?, artworkUrl? }`; `CastQueueItemInput`, `CastQueueItemSnapshot`, `SkippedCastItem`, `CanCastInput` |\n| Errors | `CastErrorCode`, `CastIdleReason`, `errorFromIdleReason`, `receiverFetchError`, `toCastError` |\n| Events and status | `CastEventMap`, `CastEventName`, `CastStateEvent`, `CastSessionEvent`, `CastSessionEventType`, `CastMediaStatus`, `CastPlayerState`, `CastDeviceVolume`, `CastTransferEvent`, `CastSeekResumeState` |\n| Handoff internals | `CastHandoffState`, `CastHandoffPhase`, `CastHandoffEvent`, `CastHandoffEffect`, `CastHandoffTransition`, `CastHandoffQueueSnapshot`, `CastHandoffQueueItem`, `CastHandoffLocalPlayer`, `CastReceiverSnapshot`, `WireCastHandoffOptions`, `initialCastHandoffState`, `projectReceiverPosition`, `CastQueueProjection`, `castabilityTables` — the pure state machine `wireCastHandoff` runs, exported for tests and custom hosts |\n| Components | `CastButtonProps`; the native pair `RnMediaCast`, `RnMediaCastButton`, `RnMediaCastButtonProps` |\n| Native events | `CastApi` (the typed surface `Cast` implements) and the raw `NativeCastStateEvent`, `NativeCastSessionEvent`, `NativeCastDevicesEvent`, `NativeCastMediaStatusEvent`, `NativeCastMediaErrorEvent`, `NativeDeviceVolumeEvent` the JS layer normalises — not API |\n| Factory and source shape | `createCast` (the factory `Cast` wraps), `CastMediaSource` |\n","readmeFilename":"README.md","_rev":"1-3b2dfe9e61fb977e2e27b28eb1a6fc2a"}