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
+
+
+
+
+
+
+
+
+
+
+ );
+};