diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx
index 97fd64e1..0d6749c6 100644
--- a/app/(auth)/(tabs)/_layout.tsx
+++ b/app/(auth)/(tabs)/_layout.tsx
@@ -11,8 +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 { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
import { Colors } from "@/constants/Colors";
@@ -120,8 +119,7 @@ export default function TabLayout() {
}}
/>
-
-
+
diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx
new file mode 100644
index 00000000..123de341
--- /dev/null
+++ b/app/(auth)/casting-player.tsx
@@ -0,0 +1,390 @@
+/**
+ * Unified Casting Player Modal
+ * Full-screen player for both Chromecast and AirPlay
+ */
+
+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, 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 { Text } from "@/components/common/Text";
+import { useCasting } from "@/hooks/useCasting";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import {
+ calculateEndingTime,
+ formatTime,
+ getPosterUrl,
+ getProtocolIcon,
+ getProtocolName,
+ truncateTitle,
+} from "@/utils/casting/helpers";
+import { PROTOCOL_COLORS } from "@/utils/casting/types";
+
+export default function CastingPlayerScreen() {
+ const insets = useSafeAreaInsets();
+ const api = useAtomValue(apiAtom);
+
+ const {
+ isConnected,
+ protocol,
+ currentItem,
+ currentDevice,
+ progress,
+ duration,
+ isPlaying,
+ isBuffering,
+ togglePlayPause,
+ skipForward,
+ skipBackward,
+ stop,
+ } = useCasting(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 (!isConnected || !currentItem || !protocol) {
+ 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 protocolColor = PROTOCOL_COLORS[protocol];
+ const protocolIcon = getProtocolIcon(protocol);
+ const protocolName = getProtocolName(protocol);
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+
+
+ {/* Connection indicator */}
+
+
+
+ {protocolName}
+
+
+
+
+
+
+ {/* 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 || protocolName}
+
+
+
+ {/* 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 Casting
+
+
+
+
+
+ );
+}
diff --git a/components/casting/CastingMiniPlayer.tsx b/components/casting/CastingMiniPlayer.tsx
new file mode 100644
index 00000000..fde9391c
--- /dev/null
+++ b/components/casting/CastingMiniPlayer.tsx
@@ -0,0 +1,185 @@
+/**
+ * Unified Casting Mini Player
+ * Works with both Chromecast and AirPlay
+ */
+
+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 { Pressable, View } from "react-native";
+import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated";
+import { Text } from "@/components/common/Text";
+import { useCasting } from "@/hooks/useCasting";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import {
+ formatTime,
+ getPosterUrl,
+ getProtocolIcon,
+ getProtocolName,
+} from "@/utils/casting/helpers";
+import { CASTING_CONSTANTS, PROTOCOL_COLORS } from "@/utils/casting/types";
+
+export const CastingMiniPlayer: React.FC = () => {
+ const api = useAtomValue(apiAtom);
+ const {
+ isConnected,
+ protocol,
+ currentItem,
+ currentDevice,
+ progress,
+ duration,
+ isPlaying,
+ togglePlayPause,
+ } = useCasting(null);
+
+ if (!isConnected || !currentItem || !protocol) {
+ return null;
+ }
+
+ const posterUrl = getPosterUrl(
+ api?.basePath,
+ currentItem.Id,
+ currentItem.ImageTags?.Primary,
+ 80,
+ 120,
+ );
+
+ const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
+ const protocolColor = PROTOCOL_COLORS[protocol];
+
+ const handlePress = () => {
+ router.push("/casting-player");
+ };
+
+ return (
+
+
+ {/* Progress bar */}
+
+
+
+
+ {/* Content */}
+
+ {/* Poster */}
+ {posterUrl && (
+
+ )}
+
+ {/* Info */}
+
+
+ {currentItem.Name}
+
+ {currentItem.SeriesName && (
+
+ {currentItem.SeriesName}
+
+ )}
+
+
+
+ {currentDevice?.name || getProtocolName(protocol)}
+
+
+ {formatTime(progress)} / {formatTime(duration)}
+
+
+
+
+ {/* Play/Pause button */}
+ {
+ e.stopPropagation();
+ togglePlayPause();
+ }}
+ style={{
+ padding: 8,
+ }}
+ >
+
+
+
+
+
+ );
+};
diff --git a/hooks/useCasting.ts b/hooks/useCasting.ts
new file mode 100644
index 00000000..dfc653d7
--- /dev/null
+++ b/hooks/useCasting.ts
@@ -0,0 +1,272 @@
+/**
+ * Unified Casting Hook
+ * Manages both Chromecast and AirPlay through a common interface
+ */
+
+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 {
+ useCastDevice,
+ useMediaStatus,
+ useRemoteMediaClient,
+} from "react-native-google-cast";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import type { CastPlayerState, CastProtocol } from "@/utils/casting/types";
+import { DEFAULT_CAST_STATE } from "@/utils/casting/types";
+
+/**
+ * Unified hook for managing casting (Chromecast + AirPlay)
+ */
+export const useCasting = (item: BaseItemDto | null) => {
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const { settings } = useSettings();
+
+ // Chromecast hooks
+ const client = useRemoteMediaClient();
+ const castDevice = useCastDevice();
+ const mediaStatus = useMediaStatus();
+
+ // Local state
+ const [state, setState] = useState(DEFAULT_CAST_STATE);
+ const progressIntervalRef = useRef(null);
+ const controlsTimeoutRef = useRef(null);
+
+ // Detect which protocol is active
+ const chromecastConnected = castDevice !== null;
+ const airplayConnected = false; // TODO: Detect AirPlay connection from video player
+
+ const activeProtocol: CastProtocol | null = chromecastConnected
+ ? "chromecast"
+ : airplayConnected
+ ? "airplay"
+ : null;
+
+ const isConnected = chromecastConnected || airplayConnected;
+
+ // Update current device
+ useEffect(() => {
+ if (chromecastConnected && castDevice) {
+ setState((prev) => ({
+ ...prev,
+ isConnected: true,
+ protocol: "chromecast",
+ currentDevice: {
+ id: castDevice.deviceId,
+ name: castDevice.friendlyName || castDevice.deviceId,
+ protocol: "chromecast",
+ },
+ }));
+ } else if (airplayConnected) {
+ setState((prev) => ({
+ ...prev,
+ isConnected: true,
+ protocol: "airplay",
+ currentDevice: {
+ id: "airplay-device",
+ name: "AirPlay Device", // TODO: Get real device name
+ protocol: "airplay",
+ },
+ }));
+ } else {
+ setState((prev) => ({
+ ...prev,
+ isConnected: false,
+ protocol: null,
+ currentDevice: null,
+ }));
+ }
+ }, [chromecastConnected, airplayConnected, castDevice]);
+
+ // Chromecast: Update playback state
+ useEffect(() => {
+ if (activeProtocol === "chromecast" && mediaStatus) {
+ setState((prev) => ({
+ ...prev,
+ isPlaying: !mediaStatus.isPaused && !mediaStatus.isBuffering,
+ progress: (mediaStatus.streamPosition || 0) * 1000,
+ duration: (mediaStatus.mediaInfo?.streamDuration || 0) * 1000,
+ isBuffering: mediaStatus.isBuffering || false,
+ }));
+ }
+ }, [mediaStatus, activeProtocol]);
+
+ // Progress reporting to Jellyfin
+ useEffect(() => {
+ if (!isConnected || !item?.Id || !user?.Id || state.progress <= 0) return;
+
+ const reportProgress = () => {
+ const progressSeconds = Math.floor(state.progress / 1000);
+ api?.playStateApi
+ .reportPlaybackProgress({
+ playbackProgressInfo: {
+ ItemId: item.Id,
+ PositionTicks: progressSeconds * 10000000,
+ IsPaused: !state.isPlaying,
+ PlayMethod:
+ activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
+ },
+ })
+ .catch(console.error);
+ };
+
+ const interval = setInterval(reportProgress, 10000);
+ return () => clearInterval(interval);
+ }, [
+ api,
+ item?.Id,
+ user?.Id,
+ state.progress,
+ state.isPlaying,
+ isConnected,
+ activeProtocol,
+ ]);
+
+ // Play/Pause controls
+ const play = useCallback(async () => {
+ if (activeProtocol === "chromecast") {
+ await client?.play();
+ }
+ // TODO: AirPlay play control
+ }, [client, activeProtocol]);
+
+ const pause = useCallback(async () => {
+ if (activeProtocol === "chromecast") {
+ await client?.pause();
+ }
+ // TODO: AirPlay pause control
+ }, [client, activeProtocol]);
+
+ const togglePlayPause = useCallback(async () => {
+ if (state.isPlaying) {
+ await pause();
+ } else {
+ await play();
+ }
+ }, [state.isPlaying, play, pause]);
+
+ // Seek controls
+ const seek = useCallback(
+ async (positionMs: number) => {
+ if (activeProtocol === "chromecast") {
+ await client?.seek({ position: positionMs / 1000 });
+ }
+ // TODO: AirPlay seek control
+ },
+ [client, activeProtocol],
+ );
+
+ const skipForward = useCallback(
+ async (seconds = 10) => {
+ const newPosition = state.progress + seconds * 1000;
+ await seek(Math.min(newPosition, state.duration));
+ },
+ [state.progress, state.duration, seek],
+ );
+
+ const skipBackward = useCallback(
+ async (seconds = 10) => {
+ const newPosition = state.progress - seconds * 1000;
+ await seek(Math.max(newPosition, 0));
+ },
+ [state.progress, seek],
+ );
+
+ // Stop and disconnect
+ const stop = useCallback(async () => {
+ if (activeProtocol === "chromecast") {
+ await client?.stop();
+ }
+ // TODO: AirPlay stop control
+
+ // Report stop to Jellyfin
+ if (api && item?.Id && user?.Id) {
+ await api.playStateApi.reportPlaybackStopped({
+ playbackStopInfo: {
+ ItemId: item.Id,
+ PositionTicks: state.progress * 10000,
+ },
+ });
+ }
+
+ setState(DEFAULT_CAST_STATE);
+ }, [client, api, item?.Id, user?.Id, state.progress, activeProtocol]);
+
+ // Volume control
+ const setVolume = useCallback(
+ async (volume: number) => {
+ const clampedVolume = Math.max(0, Math.min(1, volume));
+ if (activeProtocol === "chromecast") {
+ await client?.setVolume(clampedVolume);
+ }
+ // TODO: AirPlay volume control
+ setState((prev) => ({ ...prev, volume: clampedVolume }));
+ },
+ [client, activeProtocol],
+ );
+
+ // Controls visibility
+ const showControls = useCallback(() => {
+ setState((prev) => ({ ...prev, showControls: true }));
+
+ 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
+ isConnected,
+ protocol: activeProtocol,
+ isPlaying: state.isPlaying,
+ isBuffering: state.isBuffering,
+ currentItem: item,
+ currentDevice: state.currentDevice,
+ progress: state.progress,
+ duration: state.duration,
+ volume: state.volume,
+
+ // Availability
+ isChromecastAvailable: true, // Always available via react-native-google-cast
+ isAirPlayAvailable: Platform.OS === "ios",
+
+ // Controls
+ play,
+ pause,
+ togglePlayPause,
+ seek,
+ skipForward,
+ skipBackward,
+ stop,
+ setVolume,
+ showControls,
+ hideControls,
+ };
+};
diff --git a/utils/casting/helpers.ts b/utils/casting/helpers.ts
new file mode 100644
index 00000000..079866ef
--- /dev/null
+++ b/utils/casting/helpers.ts
@@ -0,0 +1,130 @@
+/**
+ * Unified Casting Helper Functions
+ * Common utilities for both Chromecast and AirPlay
+ */
+
+import type { CastProtocol, ConnectionQuality } from "./types";
+
+/**
+ * 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`;
+};
+
+/**
+ * Get protocol display name
+ */
+export const getProtocolName = (protocol: CastProtocol): string => {
+ switch (protocol) {
+ case "chromecast":
+ return "Chromecast";
+ case "airplay":
+ return "AirPlay";
+ }
+};
+
+/**
+ * Get protocol icon name
+ */
+export const getProtocolIcon = (
+ protocol: CastProtocol,
+): "tv" | "logo-apple" => {
+ switch (protocol) {
+ case "chromecast":
+ return "tv";
+ case "airplay":
+ return "logo-apple";
+ }
+};
diff --git a/utils/casting/types.ts b/utils/casting/types.ts
new file mode 100644
index 00000000..88a82042
--- /dev/null
+++ b/utils/casting/types.ts
@@ -0,0 +1,87 @@
+/**
+ * Unified Casting Types and Options
+ * Abstracts Chromecast and AirPlay into a common interface
+ */
+
+export type CastProtocol = "chromecast" | "airplay";
+
+export interface CastDevice {
+ id: string;
+ name: string;
+ protocol: CastProtocol;
+ type?: string;
+}
+
+export interface CastPlayerState {
+ isConnected: boolean;
+ isPlaying: boolean;
+ currentItem: any | null;
+ currentDevice: CastDevice | null;
+ protocol: CastProtocol | null;
+ progress: number;
+ duration: number;
+ volume: number;
+ showControls: boolean;
+ isBuffering: boolean;
+}
+
+export interface CastSegmentData {
+ 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 CASTING_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_CAST_STATE: CastPlayerState = {
+ isConnected: false,
+ isPlaying: false,
+ currentItem: null,
+ currentDevice: null,
+ protocol: null,
+ progress: 0,
+ duration: 0,
+ volume: 0.5,
+ showControls: true,
+ isBuffering: false,
+};
+
+export type ConnectionQuality = "excellent" | "good" | "fair" | "poor";
+
+// Protocol-specific colors for UI differentiation
+export const PROTOCOL_COLORS = {
+ chromecast: "#e50914", // Red (Google Cast)
+ airplay: "#007AFF", // Blue (Apple)
+} as const;