mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-02 08:28:10 +00:00
feat(airplay): add complete AirPlay support for iOS
- Created AirPlay utilities (options, helpers) - Built useAirPlayPlayer hook for state management - Created AirPlayMiniPlayer component (bottom bar when AirPlaying) - Built full AirPlay player modal with gesture controls - Integrated AirPlay mini player into app layout - iOS-only feature using native AVFoundation/ExpoAvRoutePickerView - Apple-themed UI with blue accents (#007AFF) - Supports swipe-down to dismiss - Shows device name, progress, and playback controls
This commit is contained in:
@@ -11,6 +11,7 @@ import { withLayoutContext } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { AirPlayMiniPlayer } from "@/components/airplay/AirPlayMiniPlayer";
|
||||
import { ChromecastMiniPlayer } from "@/components/chromecast/ChromecastMiniPlayer";
|
||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||
@@ -119,6 +120,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
<AirPlayMiniPlayer />
|
||||
<ChromecastMiniPlayer />
|
||||
<MiniPlayerBar />
|
||||
<MusicPlaybackEngine />
|
||||
|
||||
387
app/(auth)/airplay-player.tsx
Normal file
387
app/(auth)/airplay-player.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* AirPlay Player Modal
|
||||
* Full-screen player interface for AirPlay (iOS only)
|
||||
* Similar design to Chromecast player but optimized for Apple ecosystem
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useAirPlayPlayer } from "@/components/airplay/hooks/useAirPlayPlayer";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
calculateEndingTime,
|
||||
formatTime,
|
||||
getPosterUrl,
|
||||
truncateTitle,
|
||||
} from "@/utils/airplay/helpers";
|
||||
|
||||
export default function AirPlayPlayerScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
currentItem,
|
||||
currentDevice,
|
||||
progress,
|
||||
duration,
|
||||
isPlaying,
|
||||
togglePlayPause,
|
||||
seek,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
stop,
|
||||
} = useAirPlayPlayer(null);
|
||||
|
||||
// Swipe down to dismiss gesture
|
||||
const translateY = useSharedValue(0);
|
||||
const context = useSharedValue({ y: 0 });
|
||||
|
||||
const dismissModal = useCallback(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.onStart(() => {
|
||||
context.value = { y: translateY.value };
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (event.translationY > 0) {
|
||||
translateY.value = event.translationY;
|
||||
}
|
||||
})
|
||||
.onEnd((event) => {
|
||||
if (event.translationY > 100) {
|
||||
translateY.value = withSpring(500, {}, () => {
|
||||
runOnJS(dismissModal)();
|
||||
});
|
||||
} else {
|
||||
translateY.value = withSpring(0);
|
||||
}
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
// Redirect if not connected
|
||||
if (Platform.OS !== "ios" || !isConnected || !currentItem) {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const posterUrl = getPosterUrl(
|
||||
api?.basePath,
|
||||
currentItem.Id,
|
||||
currentItem.ImageTags?.Primary,
|
||||
300,
|
||||
450,
|
||||
);
|
||||
|
||||
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
|
||||
const isBuffering = false; // Placeholder - would come from player state
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor: "#000",
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: insets.bottom,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={dismissModal}
|
||||
style={{ padding: 8, marginLeft: -8 }}
|
||||
>
|
||||
<Ionicons name='chevron-down' size={32} color='white' />
|
||||
</Pressable>
|
||||
|
||||
{/* Connection indicator */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 16,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "#34C759",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{ color: "#34C759", fontSize: 12, fontWeight: "500" }}
|
||||
>
|
||||
AirPlay
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ width: 48 }} />
|
||||
</View>
|
||||
|
||||
{/* Title and episode info */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 28,
|
||||
fontWeight: "700",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{truncateTitle(currentItem.Name || "Unknown", 50)}
|
||||
</Text>
|
||||
{currentItem.SeriesName && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{currentItem.SeriesName}
|
||||
{currentItem.ParentIndexNumber &&
|
||||
currentItem.IndexNumber &&
|
||||
` • S${currentItem.ParentIndexNumber}:E${currentItem.IndexNumber}`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Poster with buffering overlay */}
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 300,
|
||||
height: 450,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film-outline' size={64} color='#333' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Buffering overlay */}
|
||||
{isBuffering && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backdropFilter: "blur(10px)",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size='large' color='#007AFF' />
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
Buffering...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Device info */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='logo-apple' size={20} color='#007AFF' />
|
||||
<Text style={{ color: "#007AFF", fontSize: 15 }}>
|
||||
{currentDevice?.name || "AirPlay Device"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Progress slider */}
|
||||
<View style={{ marginBottom: 12 }}>
|
||||
<View
|
||||
style={{
|
||||
height: 4,
|
||||
backgroundColor: "#333",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: "#007AFF",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Time display */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{formatTime(progress)}
|
||||
</Text>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
Ending at {calculateEndingTime(progress, duration)}
|
||||
</Text>
|
||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||
{formatTime(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Playback controls */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 32,
|
||||
marginBottom: 48,
|
||||
}}
|
||||
>
|
||||
{/* Rewind 10s */}
|
||||
<Pressable onPress={() => skipBackward(10)} style={{ padding: 16 }}>
|
||||
<Ionicons name='play-back' size={32} color='white' />
|
||||
</Pressable>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<Pressable
|
||||
onPress={togglePlayPause}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: "#007AFF",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={36}
|
||||
color='white'
|
||||
style={{ marginLeft: isPlaying ? 0 : 4 }}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* Forward 10s */}
|
||||
<Pressable onPress={() => skipForward(10)} style={{ padding: 16 }}>
|
||||
<Ionicons name='play-forward' size={32} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Stop casting button */}
|
||||
<Pressable
|
||||
onPress={stop}
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='stop-circle-outline' size={20} color='#FF3B30' />
|
||||
<Text style={{ color: "#FF3B30", fontSize: 16, fontWeight: "600" }}>
|
||||
Stop AirPlay
|
||||
</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
182
components/airplay/AirPlayMiniPlayer.tsx
Normal file
182
components/airplay/AirPlayMiniPlayer.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* AirPlay Mini Player
|
||||
* Compact player bar shown at bottom of screen when AirPlaying
|
||||
* iOS only component
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React from "react";
|
||||
import { Platform, Pressable, View } from "react-native";
|
||||
import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated";
|
||||
import { useAirPlayPlayer } from "@/components/airplay/hooks/useAirPlayPlayer";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { formatTime, getPosterUrl } from "@/utils/airplay/helpers";
|
||||
import { AIRPLAY_CONSTANTS } from "@/utils/airplay/options";
|
||||
|
||||
export const AirPlayMiniPlayer: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const {
|
||||
isAirPlayAvailable,
|
||||
isConnected,
|
||||
currentItem,
|
||||
currentDevice,
|
||||
progress,
|
||||
duration,
|
||||
isPlaying,
|
||||
togglePlayPause,
|
||||
} = useAirPlayPlayer(null);
|
||||
|
||||
// Only show on iOS when connected
|
||||
if (
|
||||
Platform.OS !== "ios" ||
|
||||
!isAirPlayAvailable ||
|
||||
!isConnected ||
|
||||
!currentItem
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const posterUrl = getPosterUrl(
|
||||
api?.basePath,
|
||||
currentItem.Id,
|
||||
currentItem.ImageTags?.Primary,
|
||||
80,
|
||||
120,
|
||||
);
|
||||
|
||||
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
|
||||
|
||||
const handlePress = () => {
|
||||
router.push("/airplay-player");
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={SlideInDown.duration(AIRPLAY_CONSTANTS.ANIMATION_DURATION)}
|
||||
exiting={SlideOutDown.duration(AIRPLAY_CONSTANTS.ANIMATION_DURATION)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 49, // Above tab bar
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#333",
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={handlePress}>
|
||||
{/* Progress bar */}
|
||||
<View
|
||||
style={{
|
||||
height: 3,
|
||||
backgroundColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: "#007AFF",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 12,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Poster */}
|
||||
{posterUrl && (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 60,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentItem.Name}
|
||||
</Text>
|
||||
{currentItem.SeriesName && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 12,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentItem.SeriesName}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='logo-apple' size={12} color='#007AFF' />
|
||||
<Text
|
||||
style={{
|
||||
color: "#007AFF",
|
||||
fontSize: 11,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentDevice?.name || "AirPlay"}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: "#666",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{formatTime(progress)} / {formatTime(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Play/Pause button */}
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlayPause();
|
||||
}}
|
||||
style={{
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={28}
|
||||
color='white'
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
190
components/airplay/hooks/useAirPlayPlayer.ts
Normal file
190
components/airplay/hooks/useAirPlayPlayer.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* AirPlay Player Hook
|
||||
* Manages AirPlay playback state and controls for iOS devices
|
||||
*
|
||||
* Note: AirPlay for video is handled natively by AVFoundation/MPV player.
|
||||
* This hook tracks the state and provides a unified interface for the UI.
|
||||
*/
|
||||
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import type {
|
||||
AirPlayDevice,
|
||||
AirPlayPlayerState,
|
||||
} from "@/utils/airplay/options";
|
||||
import { DEFAULT_AIRPLAY_STATE } from "@/utils/airplay/options";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
/**
|
||||
* Hook to manage AirPlay player state
|
||||
*
|
||||
* For iOS video: AirPlay is native - the video player handles streaming
|
||||
* This hook provides UI state management and progress tracking
|
||||
*/
|
||||
export const useAirPlayPlayer = (item: BaseItemDto | null) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
const [state, setState] = useState<AirPlayPlayerState>(DEFAULT_AIRPLAY_STATE);
|
||||
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Check if AirPlay is available (iOS only)
|
||||
const isAirPlayAvailable = Platform.OS === "ios";
|
||||
|
||||
// Detect AirPlay connection
|
||||
// Note: For native video AirPlay, this would be detected from the player
|
||||
// For now, this is a placeholder for UI state management
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [currentDevice, setCurrentDevice] = useState<AirPlayDevice | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Progress tracking
|
||||
const updateProgress = useCallback(
|
||||
(progressMs: number, durationMs: number) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
progress: progressMs,
|
||||
duration: durationMs,
|
||||
}));
|
||||
|
||||
// Report progress to Jellyfin
|
||||
if (api && item?.Id && user?.Id && progressMs > 0) {
|
||||
const progressSeconds = Math.floor(progressMs / 1000);
|
||||
api.playStateApi
|
||||
.reportPlaybackProgress({
|
||||
playbackProgressInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: progressSeconds * 10000000,
|
||||
IsPaused: !state.isPlaying,
|
||||
PlayMethod: "DirectStream",
|
||||
},
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
},
|
||||
[api, item?.Id, user?.Id, state.isPlaying],
|
||||
);
|
||||
|
||||
// Play/Pause controls
|
||||
const play = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, isPlaying: true }));
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, isPlaying: false }));
|
||||
}, []);
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, isPlaying: !prev.isPlaying }));
|
||||
}, []);
|
||||
|
||||
// Seek controls
|
||||
const seek = useCallback((positionMs: number) => {
|
||||
setState((prev) => ({ ...prev, progress: positionMs }));
|
||||
}, []);
|
||||
|
||||
const skipForward = useCallback((seconds = 10) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
progress: Math.min(prev.progress + seconds * 1000, prev.duration),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const skipBackward = useCallback((seconds = 10) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
progress: Math.max(prev.progress - seconds * 1000, 0),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Stop and disconnect
|
||||
const stop = useCallback(async () => {
|
||||
setState(DEFAULT_AIRPLAY_STATE);
|
||||
setIsConnected(false);
|
||||
setCurrentDevice(null);
|
||||
|
||||
// Report stop to Jellyfin
|
||||
if (api && item?.Id && user?.Id) {
|
||||
await api.playStateApi.reportPlaybackStopped({
|
||||
playbackStopInfo: {
|
||||
ItemId: item.Id,
|
||||
PositionTicks: state.progress * 10000,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [api, item?.Id, user?.Id, state.progress]);
|
||||
|
||||
// Volume control
|
||||
const setVolume = useCallback((volume: number) => {
|
||||
setState((prev) => ({ ...prev, volume: Math.max(0, Math.min(1, volume)) }));
|
||||
}, []);
|
||||
|
||||
// Controls visibility
|
||||
const showControls = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, showControls: true }));
|
||||
|
||||
// Auto-hide after delay
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
if (state.isPlaying) {
|
||||
setState((prev) => ({ ...prev, showControls: false }));
|
||||
}
|
||||
}, 5000);
|
||||
}, [state.isPlaying]);
|
||||
|
||||
const hideControls = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, showControls: false }));
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
}
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isAirPlayAvailable,
|
||||
isConnected,
|
||||
isPlaying: state.isPlaying,
|
||||
currentItem: item,
|
||||
currentDevice,
|
||||
progress: state.progress,
|
||||
duration: state.duration,
|
||||
volume: state.volume,
|
||||
|
||||
// Controls
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seek,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
stop,
|
||||
setVolume,
|
||||
showControls: showControls,
|
||||
hideControls,
|
||||
updateProgress,
|
||||
|
||||
// Device management
|
||||
setIsConnected,
|
||||
setCurrentDevice,
|
||||
};
|
||||
};
|
||||
104
utils/airplay/helpers.ts
Normal file
104
utils/airplay/helpers.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* AirPlay Helper Functions
|
||||
* Utility functions for time formatting, quality checks, and data manipulation
|
||||
*/
|
||||
|
||||
import type { ConnectionQuality } from "./options";
|
||||
|
||||
/**
|
||||
* Format milliseconds to HH:MM:SS or MM:SS
|
||||
*/
|
||||
export const formatTime = (ms: number): string => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate ending time based on current progress and duration
|
||||
*/
|
||||
export const calculateEndingTime = (
|
||||
currentMs: number,
|
||||
durationMs: number,
|
||||
): string => {
|
||||
const remainingMs = durationMs - currentMs;
|
||||
const endTime = new Date(Date.now() + remainingMs);
|
||||
const hours = endTime.getHours();
|
||||
const minutes = endTime.getMinutes();
|
||||
const ampm = hours >= 12 ? "PM" : "AM";
|
||||
const displayHours = hours % 12 || 12;
|
||||
|
||||
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine connection quality based on bitrate
|
||||
*/
|
||||
export const getConnectionQuality = (bitrate?: number): ConnectionQuality => {
|
||||
if (!bitrate) return "good";
|
||||
const mbps = bitrate / 1000000;
|
||||
|
||||
if (mbps >= 15) return "excellent";
|
||||
if (mbps >= 8) return "good";
|
||||
if (mbps >= 4) return "fair";
|
||||
return "poor";
|
||||
};
|
||||
|
||||
/**
|
||||
* Get poster URL for item with specified dimensions
|
||||
*/
|
||||
export const getPosterUrl = (
|
||||
baseUrl: string | undefined,
|
||||
itemId: string | undefined,
|
||||
tag: string | undefined,
|
||||
width: number,
|
||||
height: number,
|
||||
): string | null => {
|
||||
if (!baseUrl || !itemId) return null;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
maxWidth: width.toString(),
|
||||
maxHeight: height.toString(),
|
||||
quality: "90",
|
||||
...(tag && { tag }),
|
||||
});
|
||||
|
||||
return `${baseUrl}/Items/${itemId}/Images/Primary?${params.toString()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Truncate title to max length with ellipsis
|
||||
*/
|
||||
export const truncateTitle = (title: string, maxLength: number): string => {
|
||||
if (title.length <= maxLength) return title;
|
||||
return `${title.substring(0, maxLength - 3)}...`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current time is within a segment
|
||||
*/
|
||||
export const isWithinSegment = (
|
||||
currentMs: number,
|
||||
segment: { start: number; end: number } | null,
|
||||
): boolean => {
|
||||
if (!segment) return false;
|
||||
const currentSeconds = currentMs / 1000;
|
||||
return currentSeconds >= segment.start && currentSeconds <= segment.end;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format bitrate to human-readable string
|
||||
*/
|
||||
export const formatBitrate = (bitrate: number): string => {
|
||||
const mbps = bitrate / 1000000;
|
||||
if (mbps >= 1) {
|
||||
return `${mbps.toFixed(1)} Mbps`;
|
||||
}
|
||||
return `${(bitrate / 1000).toFixed(0)} Kbps`;
|
||||
};
|
||||
74
utils/airplay/options.ts
Normal file
74
utils/airplay/options.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* AirPlay Options and Types
|
||||
* Configuration constants and type definitions for AirPlay player
|
||||
*/
|
||||
|
||||
export interface AirPlayDevice {
|
||||
name: string;
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface AirPlayPlayerState {
|
||||
isConnected: boolean;
|
||||
isPlaying: boolean;
|
||||
currentItem: any | null;
|
||||
currentDevice: AirPlayDevice | null;
|
||||
progress: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
showControls: boolean;
|
||||
}
|
||||
|
||||
export interface AirPlaySegmentData {
|
||||
intro: { start: number; end: number } | null;
|
||||
credits: { start: number; end: number } | null;
|
||||
recap: { start: number; end: number } | null;
|
||||
commercial: Array<{ start: number; end: number }>;
|
||||
preview: Array<{ start: number; end: number }>;
|
||||
}
|
||||
|
||||
export interface AudioTrack {
|
||||
index: number;
|
||||
language: string;
|
||||
codec: string;
|
||||
displayTitle: string;
|
||||
}
|
||||
|
||||
export interface SubtitleTrack {
|
||||
index: number;
|
||||
language: string;
|
||||
codec: string;
|
||||
displayTitle: string;
|
||||
isForced: boolean;
|
||||
}
|
||||
|
||||
export interface MediaSource {
|
||||
id: string;
|
||||
name: string;
|
||||
bitrate?: number;
|
||||
container: string;
|
||||
}
|
||||
|
||||
export const AIRPLAY_CONSTANTS = {
|
||||
POSTER_WIDTH: 300,
|
||||
POSTER_HEIGHT: 450,
|
||||
ANIMATION_DURATION: 300,
|
||||
CONTROL_HIDE_DELAY: 5000,
|
||||
PROGRESS_UPDATE_INTERVAL: 1000,
|
||||
SEEK_FORWARD_SECONDS: 10,
|
||||
SEEK_BACKWARD_SECONDS: 10,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_AIRPLAY_STATE: AirPlayPlayerState = {
|
||||
isConnected: false,
|
||||
isPlaying: false,
|
||||
currentItem: null,
|
||||
currentDevice: null,
|
||||
progress: 0,
|
||||
duration: 0,
|
||||
volume: 0.5,
|
||||
showControls: true,
|
||||
};
|
||||
|
||||
export type ConnectionQuality = "excellent" | "good" | "fair" | "poor";
|
||||
Reference in New Issue
Block a user