diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index d01ca2e7..97fd64e1 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -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() { }} /> + diff --git a/app/(auth)/airplay-player.tsx b/app/(auth)/airplay-player.tsx new file mode 100644 index 00000000..a0b48a10 --- /dev/null +++ b/app/(auth)/airplay-player.tsx @@ -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 ( + + + + {/* Header */} + + + + + + {/* Connection indicator */} + + + + AirPlay + + + + + + + {/* Title and episode info */} + + + {truncateTitle(currentItem.Name || "Unknown", 50)} + + {currentItem.SeriesName && ( + + {currentItem.SeriesName} + {currentItem.ParentIndexNumber && + currentItem.IndexNumber && + ` • S${currentItem.ParentIndexNumber}:E${currentItem.IndexNumber}`} + + )} + + + {/* Poster with buffering overlay */} + + + {posterUrl ? ( + + ) : ( + + + + )} + + {/* Buffering overlay */} + {isBuffering && ( + + + + Buffering... + + + )} + + + + {/* Device info */} + + + + {currentDevice?.name || "AirPlay Device"} + + + + {/* Progress slider */} + + + + + + + {/* Time display */} + + + {formatTime(progress)} + + + Ending at {calculateEndingTime(progress, duration)} + + + {formatTime(duration)} + + + + {/* Playback controls */} + + {/* Rewind 10s */} + skipBackward(10)} style={{ padding: 16 }}> + + + + {/* Play/Pause */} + + + + + {/* Forward 10s */} + skipForward(10)} style={{ padding: 16 }}> + + + + + {/* Stop casting button */} + + + + Stop AirPlay + + + + + + ); +} diff --git a/components/airplay/AirPlayMiniPlayer.tsx b/components/airplay/AirPlayMiniPlayer.tsx new file mode 100644 index 00000000..461ed9a3 --- /dev/null +++ b/components/airplay/AirPlayMiniPlayer.tsx @@ -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 ( + + + {/* Progress bar */} + + + + + {/* Content */} + + {/* Poster */} + {posterUrl && ( + + )} + + {/* Info */} + + + {currentItem.Name} + + {currentItem.SeriesName && ( + + {currentItem.SeriesName} + + )} + + + + {currentDevice?.name || "AirPlay"} + + + {formatTime(progress)} / {formatTime(duration)} + + + + + {/* Play/Pause button */} + { + e.stopPropagation(); + togglePlayPause(); + }} + style={{ + padding: 8, + }} + > + + + + + + ); +}; diff --git a/components/airplay/hooks/useAirPlayPlayer.ts b/components/airplay/hooks/useAirPlayPlayer.ts new file mode 100644 index 00000000..082a79e0 --- /dev/null +++ b/components/airplay/hooks/useAirPlayPlayer.ts @@ -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(DEFAULT_AIRPLAY_STATE); + const progressIntervalRef = useRef(null); + const controlsTimeoutRef = useRef(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( + 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, + }; +}; diff --git a/utils/airplay/helpers.ts b/utils/airplay/helpers.ts new file mode 100644 index 00000000..eb2b05c1 --- /dev/null +++ b/utils/airplay/helpers.ts @@ -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`; +}; diff --git a/utils/airplay/options.ts b/utils/airplay/options.ts new file mode 100644 index 00000000..1c2ab38e --- /dev/null +++ b/utils/airplay/options.ts @@ -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";