diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index df1ed986..d01ca2e7 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 { ChromecastMiniPlayer } from "@/components/chromecast/ChromecastMiniPlayer"; import { MiniPlayerBar } from "@/components/music/MiniPlayerBar"; import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine"; import { Colors } from "@/constants/Colors"; @@ -118,6 +119,7 @@ export default function TabLayout() { }} /> + diff --git a/components/chromecast/ChromecastDeviceSheet.tsx b/components/chromecast/ChromecastDeviceSheet.tsx new file mode 100644 index 00000000..a7e23369 --- /dev/null +++ b/components/chromecast/ChromecastDeviceSheet.tsx @@ -0,0 +1,194 @@ +/** + * Chromecast Device Info Sheet + * Shows device details, volume control, and disconnect option + */ + +import { Ionicons } from "@expo/vector-icons"; +import React, { useState } from "react"; +import { Modal, Pressable, View } from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import type { Device } from "react-native-google-cast"; +import { useSharedValue } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; + +interface ChromecastDeviceSheetProps { + visible: boolean; + onClose: () => void; + device: Device | null; + onDisconnect: () => Promise; + volume?: number; + onVolumeChange?: (volume: number) => Promise; +} + +export const ChromecastDeviceSheet: React.FC = ({ + visible, + onClose, + device, + onDisconnect, + volume = 0.5, + onVolumeChange, +}) => { + const insets = useSafeAreaInsets(); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const volumeValue = useSharedValue(volume * 100); + + const handleDisconnect = async () => { + setIsDisconnecting(true); + try { + await onDisconnect(); + onClose(); + } catch (error) { + console.error("Failed to disconnect:", error); + } finally { + setIsDisconnecting(false); + } + }; + + const handleVolumeComplete = async (value: number) => { + if (onVolumeChange) { + await onVolumeChange(value / 100); + } + }; + + return ( + + + e.stopPropagation()} + > + {/* Header */} + + + + + Chromecast + + + + + + + + {/* Device info */} + + + + Device Name + + + {device?.friendlyName || device?.deviceId || "Unknown Device"} + + + + {device?.deviceId && ( + + + Device ID + + + {device.deviceId} + + + )} + + {/* Volume control */} + + + Volume + + {Math.round((volume || 0) * 100)}% + + + + + + { + volumeValue.value = value; + }} + onSlidingComplete={handleVolumeComplete} + /> + + + + + + {/* Disconnect button */} + + + + {isDisconnecting ? "Disconnecting..." : "Stop Casting"} + + + + + + + ); +}; diff --git a/components/chromecast/ChromecastEpisodeList.tsx b/components/chromecast/ChromecastEpisodeList.tsx new file mode 100644 index 00000000..f858fa84 --- /dev/null +++ b/components/chromecast/ChromecastEpisodeList.tsx @@ -0,0 +1,178 @@ +/** + * Episode List for Chromecast Player + * Displays list of episodes for TV shows with thumbnails + */ + +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { Image } from "expo-image"; +import React from "react"; +import { FlatList, Modal, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { truncateTitle } from "@/utils/chromecast/helpers"; + +interface ChromecastEpisodeListProps { + visible: boolean; + onClose: () => void; + currentItem: BaseItemDto | null; + episodes: BaseItemDto[]; + onSelectEpisode: (episode: BaseItemDto) => void; +} + +export const ChromecastEpisodeList: React.FC = ({ + visible, + onClose, + currentItem, + episodes, + onSelectEpisode, +}) => { + const insets = useSafeAreaInsets(); + + const renderEpisode = ({ item }: { item: BaseItemDto }) => { + const isCurrentEpisode = item.Id === currentItem?.Id; + + return ( + { + onSelectEpisode(item); + onClose(); + }} + style={{ + flexDirection: "row", + padding: 12, + backgroundColor: isCurrentEpisode ? "#e50914" : "transparent", + borderRadius: 8, + marginBottom: 8, + }} + > + {/* Thumbnail */} + + {item.ImageTags?.Primary && ( + + )} + {!item.ImageTags?.Primary && ( + + + + )} + + + {/* Episode info */} + + + {item.IndexNumber}. {truncateTitle(item.Name || "Unknown", 30)} + + + {item.Overview || "No description available"} + + {item.RunTimeTicks && ( + + {Math.round((item.RunTimeTicks / 600000000) * 10) / 10} min + + )} + + + {isCurrentEpisode && ( + + + + )} + + ); + }; + + return ( + + + {/* Header */} + + + Episodes + + + + + + + {/* Episode list */} + item.Id || ""} + contentContainerStyle={{ + padding: 16, + paddingBottom: insets.bottom + 16, + }} + showsVerticalScrollIndicator={false} + /> + + + ); +}; diff --git a/components/chromecast/ChromecastSettingsMenu.tsx b/components/chromecast/ChromecastSettingsMenu.tsx new file mode 100644 index 00000000..4a53a001 --- /dev/null +++ b/components/chromecast/ChromecastSettingsMenu.tsx @@ -0,0 +1,367 @@ +/** + * Chromecast Settings Menu + * Allows users to configure audio, subtitles, quality, and playback speed + */ + +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import React, { useState } from "react"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import type { + AudioTrack, + MediaSource, + SubtitleTrack, +} from "@/utils/chromecast/options"; + +interface ChromecastSettingsMenuProps { + visible: boolean; + onClose: () => void; + item: BaseItemDto; + mediaSources: MediaSource[]; + selectedMediaSource: MediaSource | null; + onMediaSourceChange: (source: MediaSource) => void; + audioTracks: AudioTrack[]; + selectedAudioTrack: AudioTrack | null; + onAudioTrackChange: (track: AudioTrack) => void; + subtitleTracks: SubtitleTrack[]; + selectedSubtitleTrack: SubtitleTrack | null; + onSubtitleTrackChange: (track: SubtitleTrack | null) => void; + playbackSpeed: number; + onPlaybackSpeedChange: (speed: number) => void; + showTechnicalInfo: boolean; + onToggleTechnicalInfo: () => void; +} + +const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; + +export const ChromecastSettingsMenu: React.FC = ({ + visible, + onClose, + item, + mediaSources, + selectedMediaSource, + onMediaSourceChange, + audioTracks, + selectedAudioTrack, + onAudioTrackChange, + subtitleTracks, + selectedSubtitleTrack, + onSubtitleTrackChange, + playbackSpeed, + onPlaybackSpeedChange, + showTechnicalInfo, + onToggleTechnicalInfo, +}) => { + const insets = useSafeAreaInsets(); + const [expandedSection, setExpandedSection] = useState(null); + + const toggleSection = (section: string) => { + setExpandedSection(expandedSection === section ? null : section); + }; + + const renderSectionHeader = ( + title: string, + icon: keyof typeof Ionicons.glyphMap, + sectionKey: string, + ) => ( + toggleSection(sectionKey)} + style={{ + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + borderBottomWidth: 1, + borderBottomColor: "#333", + }} + > + + + + {title} + + + + + ); + + return ( + + + e.stopPropagation()} + > + {/* Header */} + + + Playback Settings + + + + + + + + {/* Quality/Media Source */} + {renderSectionHeader("Quality", "film-outline", "quality")} + {expandedSection === "quality" && ( + + {mediaSources.map((source) => ( + { + onMediaSourceChange(source); + setExpandedSection(null); + }} + style={{ + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + backgroundColor: + selectedMediaSource?.id === source.id + ? "#2a2a2a" + : "transparent", + }} + > + + + {source.name} + + {source.bitrate && ( + + {Math.round(source.bitrate / 1000000)} Mbps + + )} + + {selectedMediaSource?.id === source.id && ( + + )} + + ))} + + )} + + {/* Audio Tracks */} + {renderSectionHeader("Audio", "musical-notes", "audio")} + {expandedSection === "audio" && ( + + {audioTracks.map((track) => ( + { + onAudioTrackChange(track); + setExpandedSection(null); + }} + style={{ + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + backgroundColor: + selectedAudioTrack?.index === track.index + ? "#2a2a2a" + : "transparent", + }} + > + + + {track.displayTitle || track.language || "Unknown"} + + {track.codec && ( + + {track.codec.toUpperCase()} + + )} + + {selectedAudioTrack?.index === track.index && ( + + )} + + ))} + + )} + + {/* Subtitle Tracks */} + {renderSectionHeader("Subtitles", "text", "subtitles")} + {expandedSection === "subtitles" && ( + + { + onSubtitleTrackChange(null); + setExpandedSection(null); + }} + style={{ + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + backgroundColor: + selectedSubtitleTrack === null + ? "#2a2a2a" + : "transparent", + }} + > + None + {selectedSubtitleTrack === null && ( + + )} + + {subtitleTracks.map((track) => ( + { + onSubtitleTrackChange(track); + setExpandedSection(null); + }} + style={{ + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + backgroundColor: + selectedSubtitleTrack?.index === track.index + ? "#2a2a2a" + : "transparent", + }} + > + + + {track.displayTitle || track.language || "Unknown"} + + {track.codec && ( + + {track.codec.toUpperCase()} + {track.isForced && " • Forced"} + + )} + + {selectedSubtitleTrack?.index === track.index && ( + + )} + + ))} + + )} + + {/* Playback Speed */} + {renderSectionHeader("Playback Speed", "speedometer", "speed")} + {expandedSection === "speed" && ( + + {PLAYBACK_SPEEDS.map((speed) => ( + { + onPlaybackSpeedChange(speed); + setExpandedSection(null); + }} + style={{ + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + backgroundColor: + playbackSpeed === speed ? "#2a2a2a" : "transparent", + }} + > + + {speed === 1 ? "Normal" : `${speed}x`} + + {playbackSpeed === speed && ( + + )} + + ))} + + )} + + {/* Technical Info Toggle */} + + + + + Show Technical Info + + + + + + + + + + + ); +};