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";