feat: airplay and chromecast for music

This commit is contained in:
Fredrik Burmester
2026-01-06 19:10:19 +01:00
parent 896c7460df
commit e1dd410f73
5 changed files with 253 additions and 6 deletions

View File

@@ -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<ViewMode>("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<CastState | null | undefined>(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}
/>
) : (
<QueueView
@@ -342,6 +378,7 @@ interface PlayerViewProps {
isFavorite: boolean | undefined;
onToggleFavorite: () => void;
onOptionsPress: () => void;
isCastConnected: boolean;
}
const PlayerView: React.FC<PlayerViewProps> = ({
@@ -370,6 +407,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
isFavorite,
onToggleFavorite,
onOptionsPress,
isCastConnected,
}) => {
const audioStream = useMemo(() => {
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
@@ -447,7 +485,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
{currentTrack.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-400 text-lg mt-1'>
<Text numberOfLines={1} className='text-neutral-400 text-lg'>
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
</Text>
</View>
@@ -577,7 +615,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
{/* Volume Slider */}
{!isTv && VolumeManager && (
<View className='flex-row items-center mb-4'>
<View className='flex-row items-center mb-6'>
<Ionicons name='volume-low' size={20} color='#666' />
<View className='flex-1 mx-3'>
<Slider
@@ -598,6 +636,39 @@ const PlayerView: React.FC<PlayerViewProps> = ({
<Ionicons name='volume-high' size={20} color='#666' />
</View>
)}
{/* AirPlay & Chromecast Buttons */}
{!isTv && (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 32,
marginBottom: 16,
}}
>
{/* AirPlay (iOS only) */}
{Platform.OS === "ios" && (
<View style={{ transform: [{ scale: 2.8 }] }}>
<ExpoAvRoutePickerView
style={{ width: 24, height: 24 }}
tintColor='#666666'
activeTintColor='#9334E9'
/>
</View>
)}
{/* Chromecast */}
<CastButton
style={{
width: 24,
height: 24,
tintColor: isCastConnected ? "#9334E9" : "#666",
transform: [{ translateY: 1 }],
}}
/>
</View>
)}
</ScrollView>
);
};

View File

@@ -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=="],

152
hooks/useMusicCast.ts Normal file
View File

@@ -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<boolean> => {
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<boolean> => {
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,
};
};

View File

@@ -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",

View File

@@ -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<string, string> = {
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";
};