mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-06 09:46:17 +00:00
feat: airplay and chromecast for music
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
7
bun.lock
7
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=="],
|
||||
|
||||
152
hooks/useMusicCast.ts
Normal file
152
hooks/useMusicCast.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
20
utils/jellyfin/audio/getAudioContentType.ts
Normal file
20
utils/jellyfin/audio/getAudioContentType.ts
Normal 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";
|
||||
};
|
||||
Reference in New Issue
Block a user