Skip to content

Commit ee1362d

Browse files
feat: redesign cast UX with unified Connect drawer (#14357)
## Summary Replaces the AirPlay/Chromecast settings toggle and the grayed-out cast icon with a Spotify-style "Connect" picker behind a single cast button on **both mobile and web**. ### Mobile (now-playing drawer) - New `IconCast` button opens a `ConnectDrawer` listing: 1. **This Device** — ends any active chromecast session 2. **AirPlay & Bluetooth** on iOS (`openAirplayDialog`) / **Bluetooth** on Android (`Linking.sendIntent('android.settings.BLUETOOTH_SETTINGS')` with `openSettings` fallback) 3. Each chromecast device discovered via `useDevices()` — tap starts a session via `SessionManager.startSession(deviceId)` - Cast button is grayed/disabled only when offline AND no chromecast devices discovered - The active device gets a primary tint + checkmark; the same goes for "This Device" when nothing is casting ### Web (desktop play bar) - New cast button placed next to the queue button - "Connect" popup with **This web browser** + **Google Cast devices** rows - "Google Cast devices" calls `audio.remote.prompt()` to surface **Chrome's built-in cast picker** — no Cast Web Sender receiver needed - Hidden entirely when `RemotePlayback` isn't supported (non-Chromium browsers) ### Settings - Removed `CastSettingsRow` (the AirPlay/Chromecast segmented control). The user no longer chooses a method up-front — they pick a target each time from the drawer. ### Harmony - New `IconCast` (Spotify-style laptop + speaker) and `IconCastSpeaker` (standalone speaker for device rows). Existing `IconCastAirplay` / `IconCastChromecast` left untouched. ### Redux (`@audius/common/store/cast`) - Dropped `method`, `updateMethod`, `CastMethod` type, `CAST_METHOD` storage key, and both persistence sagas - Kept `isCasting` and added an optional `deviceName` so the drawer can mark the active device. `GoogleCast.tsx` resolves `castSession.getCastDevice().friendlyName` and `Airplay.tsx` passes the audio route's `portName` ## Test plan - [ ] **Mobile iOS**: tap cast icon on the now-playing drawer → drawer shows This Device + AirPlay & Bluetooth + chromecast devices. AirPlay row opens the system picker. Selecting a chromecast device starts a session and the icon turns active. - [ ] **Mobile Android**: same flow but second row is labeled "Bluetooth" and opens `android.settings.BLUETOOTH_SETTINGS`. - [ ] **Mobile offline**: cast icon is grayed/disabled. - [ ] **Mobile**: "This Device" row ends an active chromecast session and the audio resumes locally. - [x] **Web (Chrome)**: cast icon appears between volume and queue. Popup shows This web browser + Google Cast devices. Clicking the cast-devices row opens Chrome's native picker. - [ ] **Web (Safari/Firefox)**: cast button is hidden (no `RemotePlayback`). - [ ] **Settings**: the AirPlay/Chromecast segmented control is gone from the iOS settings screen. ## Notes - No native iOS / Android code changes — existing Bonjour entries (`Info.plist`) and the cast receiver `222B31C8` (`AndroidManifest.xml`) are untouched. - Mobile typecheck/lint baseline is broken in the worktree environment (missing `@react-native/typescript-config/tsconfig.json` in node_modules; ~13k pre-existing errors), so a clean baseline-vs-after comparison wasn't possible locally. The one real type issue surfaced — narrowing `isCasting && ...` in the drawer — is fixed with `Boolean(...)`. - Web `cast-button/` + `PlayBar.tsx` pass ESLint cleanly. - Web dev server couldn't be started in the worktree (`packages/web/env/` missing), so no browser screenshot of the new button. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d7d7b51 commit ee1362d

23 files changed

Lines changed: 799 additions & 281 deletions

File tree

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1 @@
1-
import { call, put, takeEvery } from 'typed-redux-saga'
2-
3-
import { getContext } from '~/store/effects'
4-
5-
import { updateMethod } from './slice'
6-
import { CastMethod, CAST_METHOD } from './types'
7-
8-
/**
9-
* Sets the initial cast method based on the value in storage
10-
*/
11-
function* setInitialCastMethod() {
12-
const getLocalStorageItem = yield* getContext('getLocalStorageItem')
13-
const storageCastMethod = yield* call(getLocalStorageItem, CAST_METHOD)
14-
let method: CastMethod
15-
if (storageCastMethod === 'chromecast') {
16-
method = 'chromecast'
17-
} else {
18-
method = 'airplay'
19-
}
20-
yield* put(updateMethod({ method, persist: false }))
21-
}
22-
23-
/**
24-
* Watches for changes to the cast method and updates local storage
25-
*/
26-
function* watchUpdateCastMethod() {
27-
const setLocalStorageItem = yield* getContext('setLocalStorageItem')
28-
yield* takeEvery(
29-
updateMethod.type,
30-
function* (action: ReturnType<typeof updateMethod>) {
31-
const { method, persist } = action.payload
32-
if (persist) {
33-
setLocalStorageItem(CAST_METHOD, method)
34-
}
35-
}
36-
)
37-
}
38-
39-
export const sagas = () => {
40-
return [setInitialCastMethod, watchUpdateCastMethod]
41-
}
1+
export const sagas = () => []

packages/common/src/store/cast/selectors.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { CommonState } from '~/store/commonStore'
22

33
const getBaseState = (state: CommonState) => state.cast
44

5-
export const getMethod = (state: CommonState) => getBaseState(state).method
6-
75
export const getIsCasting = (state: CommonState) =>
86
getBaseState(state).isCasting
7+
8+
export const getMethod = (state: CommonState) => getBaseState(state).method
9+
10+
export const getDeviceName = (state: CommonState) =>
11+
getBaseState(state).deviceName

packages/common/src/store/cast/slice.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,46 @@
11
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
22

3-
import { CastMethod } from './types'
4-
5-
type CastState = {
6-
method: CastMethod
7-
isCasting: boolean
8-
}
3+
import { CastMethod, CastState } from './types'
94

105
const initialState: CastState = {
11-
method: 'airplay',
12-
isCasting: false
6+
isCasting: false,
7+
method: null,
8+
deviceName: null
139
}
1410

1511
const slice = createSlice({
1612
name: 'cast',
1713
initialState,
1814
reducers: {
19-
updateMethod: (
20-
state,
21-
{
22-
payload: { method }
23-
}: PayloadAction<{ method: CastMethod; persist?: boolean }>
24-
) => {
25-
state.method = method
26-
},
2715
setIsCasting: (
2816
state,
29-
{ payload: { isCasting } }: PayloadAction<{ isCasting: boolean }>
17+
{
18+
payload: { isCasting, method, deviceName }
19+
}: PayloadAction<{
20+
isCasting: boolean
21+
method?: CastMethod | null
22+
deviceName?: string | null
23+
}>
3024
) => {
31-
state.isCasting = isCasting
25+
if (isCasting) {
26+
state.isCasting = true
27+
if (method !== undefined) state.method = method
28+
if (deviceName !== undefined) state.deviceName = deviceName
29+
return
30+
}
31+
// Turn-off only takes effect if the caller is referring to the
32+
// currently-active method (or didn't specify one). This stops the
33+
// AirPlay route-change listener from clearing a chromecast session
34+
// that was set by GoogleCast.tsx — and vice versa.
35+
if (method && state.method && state.method !== method) return
36+
state.isCasting = false
37+
state.method = null
38+
state.deviceName = null
3239
}
3340
}
3441
})
3542

36-
export const { updateMethod, setIsCasting } = slice.actions
43+
export const { setIsCasting } = slice.actions
3744

3845
export default slice.reducer
3946

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1-
export const CAST_METHOD = 'cast'
2-
31
export type CastMethod = 'airplay' | 'chromecast'
2+
3+
export type CastState = {
4+
isCasting: boolean
5+
method: CastMethod | null
6+
deviceName: string | null
7+
}
Lines changed: 3 additions & 0 deletions
Loading

packages/harmony/src/icons/icons.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export { IconCart } from './individual/IconCart'
5353
export { IconMessages } from './individual/IconMessages'
5454
export { IconMessage } from './individual/IconMessage'
5555
export { IconStar } from './individual/IconStar'
56+
export { IconCast } from './individual/IconCast'
5657
export { IconCastAirplay } from './individual/IconCastAirplay'
5758
export { IconMessageBlock } from './individual/IconMessageBlock'
5859
export { IconMessageSlash } from './individual/IconMessageSlash'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { IconComponent } from '~harmony/components'
2+
3+
import IconSVG from '../../assets/icons/Cast.svg'
4+
5+
export const IconCast = IconSVG as IconComponent

packages/mobile/src/app/Drawers.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ChallengeRewardsDrawer } from 'app/components/challenge-rewards-drawer'
1313
import { ClaimAllRewardsDrawer } from 'app/components/challenge-rewards-drawer/ClaimAllRewardsDrawer'
1414
import { ChatActionsDrawer } from 'app/components/chat-actions-drawer'
1515
import { CoinflowOnrampDrawer } from 'app/components/coinflow-onramp-drawer/CoinflowOnrampDrawer'
16+
import { ConnectDrawer } from 'app/components/connect-drawer'
1617
import { CoinflowWithdrawDrawer } from 'app/components/coinflow-withdraw-drawer/CoinflowWithdrawDrawer'
1718
import { CreateChatActionsDrawer } from 'app/components/create-chat-actions-drawer'
1819
import { DeactivateAccountConfirmationDrawer } from 'app/components/deactivate-account-confirmation-drawer'
@@ -173,6 +174,7 @@ const nativeDrawersMap: { [DrawerName in Drawer]?: ComponentType } = {
173174
ConnectNewWallet: ConnectNewWalletDrawer,
174175
PickWinners: PickWinnersDrawer,
175176
Queue: QueueDrawer,
177+
Connect: ConnectDrawer,
176178
CoinInsightsOverflowMenu,
177179
WalletRowOverflowMenu
178180
}

packages/mobile/src/components/audio/Airplay.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,22 @@ const Airplay = () => {
4747
'deviceConnected',
4848
(device) => {
4949
console.info(`Connected to device ${JSON.stringify(device)}`)
50-
if (
51-
device &&
52-
device.devices &&
53-
device.devices[0] &&
54-
device.devices[0].portType &&
55-
device.devices[0].portType === AIRPLAY_PORT_TYPE
56-
) {
57-
dispatch(setIsCasting({ isCasting: true }))
50+
const route = device?.devices?.[0]
51+
if (route?.portType === AIRPLAY_PORT_TYPE) {
52+
dispatch(
53+
setIsCasting({
54+
isCasting: true,
55+
method: 'airplay',
56+
deviceName: route.portName ?? route.name ?? null
57+
})
58+
)
5859
} else {
59-
dispatch(setIsCasting({ isCasting: false }))
60+
// Tag the disconnect with method:'airplay' so the reducer only
61+
// clears state if AirPlay was the active method. This prevents the
62+
// listener (which fires on any audio route change, including the
63+
// one Chromecast triggers when it takes over) from clobbering
64+
// chromecast state.
65+
dispatch(setIsCasting({ isCasting: false, method: 'airplay' }))
6066
}
6167
}
6268
)

packages/mobile/src/components/audio/GoogleCast.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import {
1212
CastState,
1313
MediaPlayerState,
14+
useCastSession,
1415
useCastState,
1516
useMediaStatus,
1617
useRemoteMediaClient
@@ -40,6 +41,7 @@ export const useChromecast = () => {
4041
const client = useRemoteMediaClient()
4142
const castState = useCastState()
4243
const mediaStatus = useMediaStatus()
44+
const castSession = useCastSession()
4345
const previousCastState = usePrevious(castState)
4446

4547
const [internalCounter, setInternalCounter] = useState(0)
@@ -82,15 +84,30 @@ export const useChromecast = () => {
8284

8385
// Update our cast UI when the cast device connects
8486
useEffect(() => {
85-
switch (castState) {
86-
case CastState.CONNECTED:
87-
dispatch(setIsCasting({ isCasting: true }))
88-
break
89-
default:
90-
dispatch(setIsCasting({ isCasting: false }))
91-
break
87+
if (castState !== CastState.CONNECTED) {
88+
// Tag the disconnect with method:'chromecast' so the reducer only
89+
// clears state if chromecast was the active method — symmetric with
90+
// Airplay.tsx so the two listeners don't clobber each other.
91+
dispatch(setIsCasting({ isCasting: false, method: 'chromecast' }))
92+
return
9293
}
93-
}, [castState, dispatch])
94+
let cancelled = false
95+
const resolve = async () => {
96+
const device = await castSession?.getCastDevice()
97+
if (cancelled) return
98+
dispatch(
99+
setIsCasting({
100+
isCasting: true,
101+
method: 'chromecast',
102+
deviceName: device?.friendlyName ?? null
103+
})
104+
)
105+
}
106+
resolve()
107+
return () => {
108+
cancelled = true
109+
}
110+
}, [castState, castSession, dispatch])
94111

95112
// Ensure that the progress gets reset to 0
96113
// when a new track is played

0 commit comments

Comments
 (0)