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;