From e1dd410f738106d487737069f394d1cd9e69106a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 6 Jan 2026 19:10:19 +0100 Subject: [PATCH] feat: airplay and chromecast for music --- app/(auth)/now-playing.tsx | 79 +++++++++- bun.lock | 7 +- hooks/useMusicCast.ts | 152 ++++++++++++++++++++ package.json | 1 + utils/jellyfin/audio/getAudioContentType.ts | 20 +++ 5 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 hooks/useMusicCast.ts create mode 100644 utils/jellyfin/audio/getAudioContentType.ts diff --git a/app/(auth)/now-playing.tsx b/app/(auth)/now-playing.tsx index a81c86e7..22b355f7 100644 --- a/app/(auth)/now-playing.tsx +++ b/app/(auth)/now-playing.tsx @@ -1,3 +1,4 @@ +import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view"; import { Ionicons } from "@expo/vector-icons"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import type { @@ -7,7 +8,13 @@ import type { import { Image } from "expo-image"; import { useRouter } from "expo-router"; import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { ActivityIndicator, Dimensions, @@ -21,6 +28,7 @@ import DraggableFlatList, { type RenderItemParams, ScaleDecorator, } from "react-native-draggable-flatlist"; +import { CastButton, CastState } from "react-native-google-cast"; import { useSharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { VolumeResult } from "react-native-volume-manager"; @@ -30,7 +38,8 @@ import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; import { useFavorite } from "@/hooks/useFavorite"; -import { apiAtom } from "@/providers/JellyfinProvider"; +import { useMusicCast } from "@/hooks/useMusicCast"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { type RepeatMode, useMusicPlayer, @@ -63,6 +72,7 @@ type ViewMode = "player" | "queue"; export default function NowPlayingScreen() { const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); const router = useRouter(); const insets = useSafeAreaInsets(); const [viewMode, setViewMode] = useState("player"); @@ -70,6 +80,15 @@ export default function NowPlayingScreen() { const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + const { + isConnected: isCastConnected, + castQueue, + castState, + } = useMusicCast({ + api, + userId: user?.Id, + }); + const { currentTrack, queue, @@ -92,6 +111,7 @@ export default function NowPlayingScreen() { removeFromQueue, reorderQueue, stop, + pause, } = useMusicPlayer(); const { isFavorite, toggleFavorite } = useFavorite( @@ -110,6 +130,21 @@ export default function NowPlayingScreen() { sliderMax.value = duration > 0 ? duration : 1; }, [duration, sliderMax]); + // Auto-cast queue when Chromecast becomes connected and pause local playback + const prevCastState = useRef(null); + useEffect(() => { + if ( + castState === CastState.CONNECTED && + prevCastState.current !== CastState.CONNECTED && + queue.length > 0 + ) { + // Just connected - pause local playback and cast the queue + pause(); + castQueue({ queue, startIndex: queueIndex }); + } + prevCastState.current = castState; + }, [castState, queue, queueIndex, castQueue, pause]); + const imageUrl = useMemo(() => { if (!api || !currentTrack) return null; const albumId = currentTrack.AlbumId || currentTrack.ParentId; @@ -281,6 +316,7 @@ export default function NowPlayingScreen() { isFavorite={isFavorite} onToggleFavorite={toggleFavorite} onOptionsPress={handleOptionsPress} + isCastConnected={isCastConnected} /> ) : ( void; onOptionsPress: () => void; + isCastConnected: boolean; } const PlayerView: React.FC = ({ @@ -370,6 +407,7 @@ const PlayerView: React.FC = ({ isFavorite, onToggleFavorite, onOptionsPress, + isCastConnected, }) => { const audioStream = useMemo(() => { return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio"); @@ -447,7 +485,7 @@ const PlayerView: React.FC = ({ {currentTrack.Name} - + {currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist} @@ -577,7 +615,7 @@ const PlayerView: React.FC = ({ {/* Volume Slider */} {!isTv && VolumeManager && ( - + = ({ )} + + {/* AirPlay & Chromecast Buttons */} + {!isTv && ( + + {/* AirPlay (iOS only) */} + {Platform.OS === "ios" && ( + + + + )} + {/* Chromecast */} + + + )} ); }; diff --git a/bun.lock b/bun.lock index 4eb97403..4c59f438 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "streamyfin", "dependencies": { "@bottom-tabs/react-navigation": "1.1.0", + "@douglowder/expo-av-route-picker-view": "^0.0.5", "@expo/metro-runtime": "~6.1.1", "@expo/react-native-action-sheet": "^4.1.1", "@expo/ui": "0.2.0-beta.9", @@ -27,7 +28,7 @@ "expo-blur": "~15.0.8", "expo-brightness": "~14.0.8", "expo-build-properties": "~1.0.10", - "expo-constants": "~18.0.12", + "expo-constants": "18.0.12", "expo-dev-client": "~6.0.20", "expo-device": "~8.0.10", "expo-font": "~14.0.10", @@ -45,7 +46,7 @@ "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", - "expo-task-manager": "~14.0.9", + "expo-task-manager": "14.0.9", "expo-web-browser": "~15.0.10", "i18next": "^25.0.0", "jotai": "2.16.0", @@ -327,6 +328,8 @@ "@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="], + "@douglowder/expo-av-route-picker-view": ["@douglowder/expo-av-route-picker-view@0.0.5", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-oT4wf8aYYNfLEuZEkwZIH7CtEHKnEHWnjs6/hNwbFGEC0FnfjjWBNrQEt4fo5/gkafqa2G5ILkxndMyBZvk5dg=="], + "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="], "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], diff --git a/hooks/useMusicCast.ts b/hooks/useMusicCast.ts new file mode 100644 index 00000000..4e31c19e --- /dev/null +++ b/hooks/useMusicCast.ts @@ -0,0 +1,152 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { useCallback } from "react"; +import CastContext, { + CastState, + PlayServicesState, + useCastState, + useRemoteMediaClient, +} from "react-native-google-cast"; +import { getAudioContentType } from "@/utils/jellyfin/audio/getAudioContentType"; +import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl"; + +interface UseMusicCastOptions { + api: Api | null; + userId: string | undefined; +} + +interface CastQueueOptions { + queue: BaseItemDto[]; + startIndex: number; +} + +/** + * Hook for casting music to Chromecast with full queue support + */ +export const useMusicCast = ({ api, userId }: UseMusicCastOptions) => { + const client = useRemoteMediaClient(); + const castState = useCastState(); + + const isConnected = castState === CastState.CONNECTED; + + /** + * Get album art URL for a track + */ + const getAlbumArtUrl = useCallback( + (track: BaseItemDto): string | undefined => { + if (!api) return undefined; + const albumId = track.AlbumId || track.ParentId; + if (albumId) { + return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`; + } + return `${api.basePath}/Items/${track.Id}/Images/Primary?maxHeight=600&maxWidth=600`; + }, + [api], + ); + + /** + * Cast a queue of tracks to Chromecast + * Uses native queue support for seamless track transitions + */ + const castQueue = useCallback( + async ({ queue, startIndex }: CastQueueOptions): Promise => { + if (!client || !api || !userId) { + console.warn("Cannot cast: missing client, api, or userId"); + return false; + } + + try { + // Check Play Services state (Android) + const state = await CastContext.getPlayServicesState(); + if (state && state !== PlayServicesState.SUCCESS) { + CastContext.showPlayServicesErrorDialog(state); + return false; + } + + // Build queue items - limit to 100 tracks due to Cast SDK message size limit + const queueToSend = queue.slice(0, 100); + const queueItems = await Promise.all( + queueToSend.map(async (track) => { + const streamResult = await getAudioStreamUrl( + api, + userId, + track.Id!, + ); + if (!streamResult) { + throw new Error( + `Failed to get stream URL for track: ${track.Name}`, + ); + } + + const contentType = getAudioContentType( + streamResult.mediaSource?.Container, + ); + + return { + mediaInfo: { + contentUrl: streamResult.url, + contentType, + metadata: { + type: "musicTrack" as const, + title: track.Name || "Unknown Track", + artist: track.AlbumArtist || track.Artists?.join(", ") || "", + albumName: track.Album || "", + images: getAlbumArtUrl(track) + ? [{ url: getAlbumArtUrl(track)! }] + : [], + }, + }, + autoplay: true, + preloadTime: 10, // Preload 10 seconds before track ends + }; + }), + ); + + // Load media with queue + await client.loadMedia({ + queueData: { + items: queueItems, + startIndex: Math.min(startIndex, queueItems.length - 1), + }, + }); + + // Show expanded controls + CastContext.showExpandedControls(); + + return true; + } catch (error) { + console.error("Failed to cast music queue:", error); + return false; + } + }, + [client, api, userId, getAlbumArtUrl], + ); + + /** + * Cast a single track to Chromecast + */ + const castTrack = useCallback( + async (track: BaseItemDto): Promise => { + return castQueue({ queue: [track], startIndex: 0 }); + }, + [castQueue], + ); + + /** + * Stop casting and disconnect + */ + const stopCasting = useCallback(async () => { + if (client) { + await client.stop(); + } + }, [client]); + + return { + client, + isConnected, + castState, + castQueue, + castTrack, + stopCasting, + }; +}; diff --git a/package.json b/package.json index f7bbc732..fa263961 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@bottom-tabs/react-navigation": "1.1.0", + "@douglowder/expo-av-route-picker-view": "^0.0.5", "@expo/metro-runtime": "~6.1.1", "@expo/react-native-action-sheet": "^4.1.1", "@expo/ui": "0.2.0-beta.9", diff --git a/utils/jellyfin/audio/getAudioContentType.ts b/utils/jellyfin/audio/getAudioContentType.ts new file mode 100644 index 00000000..fc50e2f8 --- /dev/null +++ b/utils/jellyfin/audio/getAudioContentType.ts @@ -0,0 +1,20 @@ +/** + * Maps Jellyfin audio container types to MIME types for Chromecast + */ +export const getAudioContentType = (container?: string | null): string => { + if (!container) return "audio/mpeg"; + + const map: Record = { + mp3: "audio/mpeg", + aac: "audio/aac", + m4a: "audio/mp4", + flac: "audio/flac", + wav: "audio/wav", + opus: "audio/opus", + ogg: "audio/ogg", + wma: "audio/x-ms-wma", + webm: "audio/webm", + }; + + return map[container.toLowerCase()] ?? "audio/mpeg"; +};