Files
streamyfin/app/(auth)/casting-player.tsx
Uruk 68d64fec9c feat: Enhances casting player with trickplay
Implements trickplay functionality with preview images to improve the casting player's seeking experience.

Adds a progress slider with trickplay preview, allowing users to scrub through media with visual feedback. Integrates device volume control and mute functionality to the Chromecast device sheet. Also fixes minor bugs and improves UI.
2026-05-21 00:47:31 +02:00

1533 lines
53 KiB
TypeScript

/**
* Unified Casting Player Modal
* Protocol-agnostic full-screen player for all supported casting protocols
*/
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { router, Stack } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Dimensions,
Pressable,
ScrollView,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import GoogleCast, {
CastState,
MediaPlayerState,
MediaStreamType,
useCastDevice,
useCastState,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu";
import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
import { Text } from "@/components/common/Text";
import { useCasting } from "@/hooks/useCasting";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
calculateEndingTime,
formatTime,
getPosterUrl,
shouldShowNextEpisodeCountdown,
truncateTitle,
} from "@/utils/casting/helpers";
import type { CastProtocol } from "@/utils/casting/types";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function CastingPlayerScreen() {
const insets = useSafeAreaInsets();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const { t } = useTranslation();
// Get raw Chromecast state directly - same as old implementation
const castState = useCastState();
const mediaStatus = useMediaStatus();
const castDevice = useCastDevice();
// Keep hook active for connection - used by remoteMediaClient from useCasting
useRemoteMediaClient();
// Shared values for progress slider (must be initialized before any early returns)
const sliderProgress = useSharedValue(0);
const sliderMin = useSharedValue(0);
const sliderMax = useSharedValue(100);
const isScrubbing = useRef(false);
// Trickplay time display
const [trickplayTime, setTrickplayTime] = useState({
hours: 0,
minutes: 0,
seconds: 0,
});
// Track scrub percentage for trickplay bubble positioning
const [scrubPercentage, setScrubPercentage] = useState(0);
// Live progress tracking - update every second
const [liveProgress, setLiveProgress] = useState(0);
// Fetch full item data from Jellyfin by ID
const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null);
const [_isFetchingItem, setIsFetchingItem] = useState(false);
useEffect(() => {
const fetchItemData = async () => {
const itemId = mediaStatus?.mediaInfo?.contentId;
if (!itemId || !api || !user?.Id) return;
setIsFetchingItem(true);
try {
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user.Id,
});
console.log("[Casting Player] Fetched full item from API:", {
Type: res.data.Type,
Name: res.data.Name,
SeriesName: res.data.SeriesName,
SeasonId: res.data.SeasonId,
ParentIndexNumber: res.data.ParentIndexNumber,
IndexNumber: res.data.IndexNumber,
});
setFetchedItem(res.data);
} catch (error) {
console.error("[Casting Player] Failed to fetch item:", error);
} finally {
setIsFetchingItem(false);
}
};
fetchItemData();
}, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]);
useEffect(() => {
// Initialize with actual position
if (mediaStatus?.streamPosition) {
setLiveProgress(mediaStatus.streamPosition);
}
// Update every second when playing
const interval = setInterval(() => {
if (
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
mediaStatus?.streamPosition !== undefined
) {
setLiveProgress((prev) => prev + 1);
} else if (mediaStatus?.streamPosition !== undefined) {
// Sync with actual position when paused/buffering
setLiveProgress(mediaStatus.streamPosition);
}
}, 1000);
return () => clearInterval(interval);
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
// Extract item from customData, or use fetched item, or create a minimal fallback
const currentItem = useMemo(() => {
// Priority 1: Use fetched item from API (most reliable)
if (fetchedItem) {
console.log("[Casting Player] Using fetched item from API");
return fetchedItem;
}
// Priority 2: Try customData from mediaStatus
const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null;
if (customData?.Type && customData.Type !== "Movie") {
// Only use customData if it has a real Type (not default fallback)
console.log("[Casting Player] Using customData item:", {
Type: customData.Type,
Name: customData.Name,
});
return customData;
}
// Priority 3: Create minimal fallback while loading
if (mediaStatus?.mediaInfo) {
const { contentId, metadata } = mediaStatus.mediaInfo;
console.log(
"[Casting Player] Using minimal fallback item (still loading)",
);
return {
Id: contentId,
Name: metadata?.title || "Unknown",
Type: "Movie", // Temporary until API fetch completes
ServerId: "",
} as BaseItemDto;
}
return null;
}, [fetchedItem, mediaStatus?.mediaInfo]);
// Derive state from raw Chromecast hooks
const protocol: CastProtocol = "chromecast";
const progress = liveProgress; // Use live-updating progress
const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0;
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING;
const currentDevice = castDevice?.friendlyName ?? null;
// Trickplay for seeking preview - use fetched item with full data
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
fetchedItem ?? ({} as BaseItemDto),
);
// Update slider max when duration changes
useEffect(() => {
if (duration > 0) {
sliderMax.value = duration * 1000; // Convert to milliseconds
}
}, [duration, sliderMax]);
// Update slider progress when not scrubbing
useEffect(() => {
if (!isScrubbing.current && progress > 0) {
sliderProgress.value = progress * 1000; // Convert to milliseconds
}
}, [progress, sliderProgress]);
// Only use casting controls if we have a current item to avoid "No session" errors
const castingControls = useCasting(currentItem);
const {
togglePlayPause,
skipForward,
skipBackward,
setVolume,
volume,
remoteMediaClient,
} = currentItem
? castingControls
: {
togglePlayPause: async () => {},
skipForward: async () => {},
skipBackward: async () => {},
setVolume: async () => {},
volume: 1,
remoteMediaClient: null,
};
// Modal states
const [showEpisodeList, setShowEpisodeList] = useState(false);
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
// Track selection states
const [selectedAudioTrackIndex, setSelectedAudioTrackIndex] = useState<
number | null
>(null);
const [selectedSubtitleTrackIndex, setSelectedSubtitleTrackIndex] = useState<
number | null
>(null);
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
// Function to reload media with new audio/subtitle/quality settings
const reloadWithSettings = useCallback(
async (options: {
audioIndex?: number;
subtitleIndex?: number | null;
bitrateValue?: number;
}) => {
if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) {
console.warn("[Casting Player] Cannot reload - missing required data");
return;
}
try {
// Save current playback position
const currentPosition = mediaStatus?.streamPosition ?? 0;
console.log(
"[Casting Player] Reloading stream at position:",
currentPosition,
);
// Get new stream URL with updated settings
const enableH265 = settings.enableH265ForChromecast;
const data = await getStreamUrl({
api,
item: currentItem,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: Math.floor(currentPosition * 10000000), // Convert seconds to ticks
userId: user.Id,
audioStreamIndex:
options.audioIndex ?? selectedAudioTrackIndex ?? undefined,
subtitleStreamIndex: options.subtitleIndex ?? undefined,
maxStreamingBitrate: options.bitrateValue,
});
if (!data?.url) {
console.error("[Casting Player] Failed to get stream URL");
return;
}
console.log("[Casting Player] Reloading with new URL:", data.url);
// Reload media with new URL
await remoteMediaClient.loadMedia({
mediaInfo: {
contentId: currentItem.Id,
contentUrl: data.url,
contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED,
streamDuration: currentItem.RunTimeTicks
? currentItem.RunTimeTicks / 10000000
: undefined,
customData: currentItem,
metadata:
currentItem.Type === "Episode"
? {
type: "tvShow",
title: currentItem.Name || "",
episodeNumber: currentItem.IndexNumber || 0,
seasonNumber: currentItem.ParentIndexNumber || 0,
seriesTitle: currentItem.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item: currentItem,
quality: 90,
width: 2000,
})!,
},
],
}
: currentItem.Type === "Movie"
? {
type: "movie",
title: currentItem.Name || "",
subtitle: currentItem.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item: currentItem,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: currentItem.Name || "",
subtitle: currentItem.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item: currentItem,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: currentPosition, // Resume at same position
});
console.log("[Casting Player] Stream reloaded successfully");
} catch (error) {
console.error("[Casting Player] Failed to reload stream:", error);
}
},
[
api,
user?.Id,
currentItem,
remoteMediaClient,
mediaStatus?.streamPosition,
settings.enableH265ForChromecast,
selectedAudioTrackIndex,
],
);
// Fetch season data for season poster
useEffect(() => {
if (
currentItem?.Type !== "Episode" ||
!currentItem.SeasonId ||
!api ||
!user?.Id
)
return;
const fetchSeasonData = async () => {
try {
console.log(
`[Casting Player] Fetching season data for SeasonId: ${currentItem.SeasonId}`,
);
const userLibraryApi = getUserLibraryApi(api);
const response = await userLibraryApi.getItem({
itemId: currentItem.SeasonId!,
userId: user.Id!,
});
console.log("[Casting Player] Season data fetched:", {
Id: response.data.Id,
Name: response.data.Name,
ImageTags: response.data.ImageTags,
ParentPrimaryImageItemId: response.data.ParentPrimaryImageItemId,
});
setSeasonData(response.data);
} catch (error) {
console.error("[Casting Player] Failed to fetch season data:", error);
setSeasonData(null);
}
};
fetchSeasonData();
}, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]);
const availableAudioTracks = useMemo(() => {
if (!currentItem?.MediaStreams) return [];
return currentItem.MediaStreams.filter(
(stream) => stream.Type === "Audio",
).map((stream) => ({
index: stream.Index ?? 0,
language: stream.Language || "Unknown",
displayTitle:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(),
codec: stream.Codec || "Unknown",
channels: stream.Channels,
bitrate: stream.BitRate,
}));
}, [currentItem?.MediaStreams]);
const availableSubtitleTracks = useMemo(() => {
if (!currentItem?.MediaStreams) return [];
return currentItem.MediaStreams.filter(
(stream) => stream.Type === "Subtitle",
).map((stream) => ({
index: stream.Index ?? 0,
language: stream.Language || "Unknown",
displayTitle:
stream.DisplayTitle ||
`${stream.Language || "Unknown"}${stream.IsForced ? " (Forced)" : ""}${stream.Title ? ` - ${stream.Title}` : ""}`,
codec: stream.Codec || "Unknown",
isForced: stream.IsForced || false,
isExternal: stream.IsExternal || false,
}));
}, [currentItem?.MediaStreams]);
const availableMediaSources = useMemo(() => {
// Get the original source bitrate
const originalBitrate =
currentItem?.MediaSources?.[0]?.Bitrate ||
currentItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ||
20000000; // Default to 20Mbps if unknown
// Generate bitrate variants
const variants = [
{
id: `${currentItem?.Id}-max`,
name: "Max",
bitrate: originalBitrate,
container: currentItem?.MediaSources?.[0]?.Container || "mp4",
},
{
id: `${currentItem?.Id}-8mbps`,
name: "8 Mb/s",
bitrate: 8000000,
container: currentItem?.MediaSources?.[0]?.Container || "mp4",
},
{
id: `${currentItem?.Id}-4mbps`,
name: "4 Mb/s",
bitrate: 4000000,
container: currentItem?.MediaSources?.[0]?.Container || "mp4",
},
{
id: `${currentItem?.Id}-2mbps`,
name: "2 Mb/s",
bitrate: 2000000,
container: currentItem?.MediaSources?.[0]?.Container || "mp4",
},
{
id: `${currentItem?.Id}-1mbps`,
name: "1 Mb/s",
bitrate: 1000000,
container: currentItem?.MediaSources?.[0]?.Container || "mp4",
},
];
return variants;
}, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]);
// Auto-select stereo audio track for better Chromecast compatibility
// Note: This only updates the UI state. The actual audio track change requires
// regenerating the stream URL, which would be disruptive on initial load.
// The user can manually switch audio tracks if needed.
useEffect(() => {
if (!remoteMediaClient || !mediaStatus?.mediaInfo) return;
const currentTrack = availableAudioTracks.find(
(t) => t.index === selectedAudioTrackIndex,
);
// If current track is 5.1+ audio, suggest stereo in the UI
if (currentTrack && (currentTrack.channels || 0) > 2) {
const stereoTrack = availableAudioTracks.find((t) => t.channels === 2);
if (stereoTrack && stereoTrack.index !== selectedAudioTrackIndex) {
console.log(
"[Audio] Note: 5.1 audio detected. Stereo available:",
currentTrack.displayTitle,
"->",
stereoTrack.displayTitle,
);
// Auto-select stereo in UI (user can manually trigger reload)
setSelectedAudioTrackIndex(stereoTrack.index);
}
}
}, [
mediaStatus?.mediaInfo,
availableAudioTracks,
remoteMediaClient,
selectedAudioTrackIndex,
]);
// Fetch episodes for TV shows
useEffect(() => {
if (currentItem?.Type !== "Episode" || !currentItem.SeriesId || !api)
return;
const fetchEpisodes = async () => {
try {
const tvShowsApi = getTvShowsApi(api);
// Fetch ALL episodes from ALL seasons by removing seasonId filter
const response = await tvShowsApi.getEpisodes({
seriesId: currentItem.SeriesId!,
// Don't filter by seasonId - get all seasons
userId: api.accessToken ? undefined : "",
});
const episodeList = response.data.Items || [];
setEpisodes(episodeList);
// Find next episode
const currentIndex = episodeList.findIndex(
(ep) => ep.Id === currentItem.Id,
);
if (currentIndex >= 0 && currentIndex < episodeList.length - 1) {
setNextEpisode(episodeList[currentIndex + 1]);
} else {
setNextEpisode(null);
}
} catch (error) {
console.error("Failed to fetch episodes:", error);
}
};
fetchEpisodes();
}, [
currentItem?.Type,
currentItem?.SeriesId,
currentItem?.SeasonId,
currentItem?.Id,
api,
]);
// Auto-navigate to player when casting starts (if not already on player screen)
useEffect(() => {
if (mediaStatus?.currentItemId && !currentItem) {
// New media started casting while we're not on the player
console.log("[Casting Player] Auto-navigating to player for new cast");
router.replace("/casting-player" as any);
}
}, [mediaStatus?.currentItemId, currentItem, router]);
// Segment detection (skip intro/credits) - use progress in seconds for accurate detection
const { currentSegment, skipIntro, skipCredits, skipSegment } =
useChromecastSegments(currentItem, progress * 1000, false);
// Swipe down to dismiss gesture
const translateY = useSharedValue(0);
const context = useSharedValue({ y: 0 });
const dismissModal = useCallback(() => {
// Navigate immediately without animation to prevent crashes
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}, [router]);
const panGesture = Gesture.Pan()
.onStart(() => {
context.value = { y: translateY.value };
})
.onUpdate((event) => {
// Only allow downward swipes from top of screen
if (event.translationY > 0) {
translateY.value = context.value.y + event.translationY;
}
})
.onEnd((event) => {
// Dismiss if swiped down more than 150px or fast swipe
if (event.translationY > 150 || event.velocityY > 600) {
// Animate down and dismiss
translateY.value = withSpring(
1000,
{
damping: 20,
stiffness: 90,
},
() => {
runOnJS(dismissModal)();
},
);
} else {
// Spring back to position
translateY.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
// Memoize expensive calculations (before early return)
const posterUrl = useMemo(() => {
if (!api?.basePath || !currentItem?.Id) return null;
// For episodes, use SEASON poster instead of episode poster
if (currentItem.Type === "Episode" && seasonData?.Id) {
// Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags
const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id;
const seasonImageTag = seasonData.ImageTags?.Primary;
console.log(
`[Casting Player] Using season poster for ${seasonData.Name}`,
{ imageItemId, seasonImageTag },
);
return seasonImageTag
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`;
}
// Fallback to item poster for non-episodes or if season data not loaded
console.log(
`[Casting Player] Using fallback poster for ${currentItem.Name}`,
{
Type: currentItem.Type,
hasSeasonData: !!seasonData?.Id,
},
);
return getPosterUrl(
api.basePath,
currentItem.Id,
currentItem.ImageTags?.Primary,
260,
390,
);
}, [
api?.basePath,
currentItem?.Id,
currentItem?.Type,
seasonData?.Id,
seasonData?.ImageTags?.Primary,
currentItem?.ImageTags?.Primary,
]);
const protocolColor = "#a855f7"; // Streamyfin purple
const _showNextEpisode = useMemo(() => {
if (currentItem?.Type !== "Episode" || !nextEpisode) return false;
const remaining = duration - progress;
return shouldShowNextEpisodeCountdown(remaining, true, 30);
}, [currentItem?.Type, nextEpisode, duration, progress]);
// Redirect if not connected - check CastState like old implementation
useEffect(() => {
// Redirect immediately when disconnected or no devices
if (
castState === CastState.NOT_CONNECTED ||
castState === CastState.NO_DEVICES_AVAILABLE
) {
// Use setTimeout to avoid state update during render
const timer = setTimeout(() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}, 100);
return () => clearTimeout(timer);
}
}, [castState]);
// Also redirect if mediaStatus disappears (media ended or stopped)
useEffect(() => {
if (castState === CastState.CONNECTED && !mediaStatus) {
const timer = setTimeout(() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}, 500); // Small delay to allow for media transitions
return () => clearTimeout(timer);
}
}, [castState, mediaStatus]);
// Show loading while connecting
if (castState === CastState.CONNECTING) {
return (
<View
style={{
flex: 1,
backgroundColor: "#000",
alignItems: "center",
justifyContent: "center",
}}
>
<ActivityIndicator size='large' color='#fff' />
<Text style={{ color: "#fff", marginTop: 16 }}>
Connecting to Chromecast...
</Text>
</View>
);
}
// Don't render if not connected or no media playing
if (castState !== CastState.CONNECTED || !mediaStatus || !currentItem) {
return null;
}
return (
<>
<Stack.Screen
options={{
headerShown: false,
title: "",
presentation: "fullScreenModal",
animation: "slide_from_bottom",
}}
/>
<GestureDetector gesture={panGesture}>
<Animated.View
style={[
{
flex: 1,
backgroundColor: "#000",
},
animatedStyle,
]}
>
{/* Header - Fixed at top */}
<View
style={{
position: "absolute",
top: insets.top + 8,
left: 20,
right: 20,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
zIndex: 100,
}}
>
<Pressable
onPress={dismissModal}
style={{ padding: 8, marginLeft: -8 }}
>
<Ionicons name='chevron-down' size={32} color='white' />
</Pressable>
{/* Connection indicator */}
<Pressable
onPress={() => setShowDeviceSheet(true)}
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: "#1a1a1a",
borderRadius: 16,
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: protocolColor,
}}
/>
<Text
style={{
color: protocolColor,
fontSize: 14,
fontWeight: "500",
}}
>
{currentDevice || "Unknown Device"}
</Text>
</Pressable>
<Pressable
onPress={() => setShowSettings(true)}
style={{ padding: 8, marginRight: -8 }}
>
<Ionicons name='settings-outline' size={24} color='white' />
</Pressable>
</View>
{/* Title Area */}
<View
style={{
position: "absolute",
top: insets.top + 50,
left: 0,
right: 0,
zIndex: 95,
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.8)",
paddingVertical: 16,
paddingHorizontal: 20,
}}
>
{/* Title */}
<Text
style={{
color: "white",
fontSize: 20,
fontWeight: "700",
textAlign: "center",
marginBottom: 6,
}}
>
{truncateTitle(currentItem.Name || "Unknown", 50)}
</Text>
{/* Grey episode/season info */}
{currentItem.Type === "Episode" &&
currentItem.ParentIndexNumber !== undefined &&
currentItem.IndexNumber !== undefined && (
<Text
style={{
color: "#999",
fontSize: 15,
textAlign: "center",
}}
>
{t("casting_player.season_episode_format", {
season: currentItem.ParentIndexNumber,
episode: currentItem.IndexNumber,
})}
</Text>
)}
</View>
{/* Scrollable content area */}
<ScrollView
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: insets.top + 160,
paddingBottom: insets.bottom + 500,
}}
showsVerticalScrollIndicator={false}
>
{/* Poster with buffering overlay */}
<View
style={{
alignItems: "center",
marginBottom: 40,
}}
>
<View
style={{
width: 280,
height: 420,
borderRadius: 12,
overflow: "hidden",
position: "relative",
}}
>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "#1a1a1a",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='film-outline' size={64} color='#333' />
</View>
)}
{/* Skip intro/credits bar at bottom of poster */}
{currentSegment && (
<Pressable
onPress={async () => {
if (!remoteMediaClient) return;
try {
const seekFn = async (positionMs: number) => {
if (
mediaStatus?.playerState ===
MediaPlayerState.PLAYING ||
mediaStatus?.playerState === MediaPlayerState.PAUSED
) {
await remoteMediaClient.seek({
position: positionMs / 1000,
});
}
};
if (currentSegment.type === "intro") {
await skipIntro(seekFn);
} else if (currentSegment.type === "credits") {
await skipCredits(seekFn);
} else {
await skipSegment(seekFn);
}
} catch (error) {
console.error("[Casting Player] Skip error:", error);
}
}}
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: protocolColor,
paddingVertical: 12,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<Ionicons
name='play-skip-forward'
size={18}
color='white'
/>
<Text
style={{
color: "white",
fontSize: 14,
fontWeight: "600",
}}
>
{currentSegment.type === "intro"
? t("player.skip_intro")
: currentSegment.type === "credits"
? t("player.skip_outro")
: `Skip ${currentSegment.type}`}
</Text>
</Pressable>
)}
{/* Buffering overlay */}
{isBuffering && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.7)",
justifyContent: "center",
alignItems: "center",
}}
>
<ActivityIndicator size='large' color={protocolColor} />
<Text
style={{
color: "white",
fontSize: 16,
marginTop: 16,
}}
>
{t("casting_player.buffering")}
</Text>
</View>
)}
</View>
</View>
</ScrollView>
{/* Fixed 4-button control row for episodes - positioned independently */}
{currentItem.Type === "Episode" && (
<View
style={{
position: "absolute",
bottom: insets.bottom + 200,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 16,
paddingHorizontal: 20,
}}
>
{/* Episodes button */}
<Pressable
onPress={() => setShowEpisodeList(true)}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='list' size={22} color='white' />
</Pressable>
{/* Previous episode button */}
<Pressable
onPress={async () => {
const currentIndex = episodes.findIndex(
(ep) => ep.Id === currentItem.Id,
);
if (currentIndex > 0 && remoteMediaClient) {
const previousEp = episodes[currentIndex - 1];
console.log("Previous episode:", previousEp.Name);
}
}}
disabled={
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
opacity:
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
? 0.4
: 1,
}}
>
<Ionicons name='play-skip-back' size={22} color='white' />
</Pressable>
{/* Next episode button */}
<Pressable
onPress={async () => {
if (nextEpisode && remoteMediaClient) {
console.log("Next episode:", nextEpisode.Name);
}
}}
disabled={!nextEpisode}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
opacity: nextEpisode ? 1 : 0.4,
}}
>
<Ionicons name='play-skip-forward' size={22} color='white' />
</Pressable>
{/* Stop playback button - stops media but stays connected to Chromecast */}
<Pressable
onPress={async () => {
try {
// Stop the current media playback (don't disconnect from Chromecast)
if (remoteMediaClient) {
await remoteMediaClient.stop();
}
// Navigate back/close the player (mini player will disappear since no media is playing)
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
} catch (error) {
console.error(
"[Casting Player] Error stopping playback:",
error,
);
// Navigate anyway
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}
}}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='stop-circle' size={22} color='white' />
</Pressable>
</View>
)}
{/* Fixed bottom controls area */}
<View
style={{
position: "absolute",
bottom: insets.bottom + 10,
left: 20,
right: 20,
zIndex: 98,
}}
>
{/* Progress slider with trickplay preview */}
<View style={{ marginTop: 8, height: 40 }}>
<Slider
style={{ width: "100%", height: 40 }}
progress={sliderProgress}
minimumValue={sliderMin}
maximumValue={sliderMax}
theme={{
maximumTrackTintColor: "#333",
minimumTrackTintColor: protocolColor,
bubbleBackgroundColor: protocolColor,
bubbleTextColor: "#fff",
}}
onSlidingStart={() => {
isScrubbing.current = true;
}}
onValueChange={(value) => {
// Calculate trickplay preview
const progressInTicks = msToTicks(value);
calculateTrickplayUrl(progressInTicks);
// Update time display for trickplay bubble
const progressInSeconds = Math.floor(
ticksToSeconds(progressInTicks),
);
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTrickplayTime({ hours, minutes, seconds });
// Track scrub percentage for bubble positioning
const durationMs = duration * 1000;
if (durationMs > 0) {
setScrubPercentage(value / durationMs);
}
}}
onSlidingComplete={(value) => {
isScrubbing.current = false;
// Seek to the position (value is in milliseconds, convert to seconds)
const positionSeconds = value / 1000;
if (remoteMediaClient && duration > 0) {
remoteMediaClient
.seek({ position: positionSeconds })
.catch((error) => {
console.error("[Casting Player] Seek error:", error);
});
}
}}
renderBubble={() => {
// Calculate bubble position with edge clamping
const screenWidth = Dimensions.get("window").width;
const containerPadding = 20; // left/right padding of slider container (matches style)
const thumbWidth = 16; // matches thumbWidth prop on Slider
const sliderWidth = screenWidth - containerPadding * 2;
// Adjust thumb position to account for thumb width affecting travel range
const effectiveTrackWidth = sliderWidth - thumbWidth;
const thumbPosition =
thumbWidth / 2 + scrubPercentage * effectiveTrackWidth;
if (!trickPlayUrl || !trickplayInfo) {
// Show simple time bubble when no trickplay
const timeBubbleWidth = 80;
// Clamp position so bubble stays on screen
// minLeft prevents going off left edge, maxLeft prevents going off right edge
const minLeft = -thumbPosition;
const maxLeft =
sliderWidth - thumbPosition - timeBubbleWidth;
const centeredLeft = -timeBubbleWidth / 2;
const clampedLeft = Math.max(
minLeft,
Math.min(maxLeft, centeredLeft),
);
return (
<View
style={{
position: "absolute",
bottom: 20,
left: clampedLeft,
backgroundColor: protocolColor,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
}}
>
<Text
style={{
color: "#fff",
fontSize: 14,
fontWeight: "600",
}}
>
{`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${
trickplayTime.minutes < 10
? `0${trickplayTime.minutes}`
: trickplayTime.minutes
}:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`}
</Text>
</View>
);
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 220; // Larger preview for casting player
const tileHeight =
tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
// Calculate clamped position for trickplay preview
// minLeft: furthest left (when thumb is at left edge)
// maxLeft: furthest right (when thumb is at right edge)
const minLeft = -thumbPosition;
const maxLeft = sliderWidth - thumbPosition - tileWidth;
const centeredLeft = -tileWidth / 2;
const clampedLeft = Math.max(
minLeft,
Math.min(maxLeft, centeredLeft),
);
return (
<View
style={{
position: "absolute",
bottom: 20,
left: clampedLeft,
width: tileWidth,
alignItems: "center",
}}
>
{/* Trickplay image preview */}
<View
style={{
width: tileWidth,
height: tileHeight,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
<Image
cachePolicy='memory-disk'
style={{
width:
tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
height:
(tileWidth /
(trickplayInfo.aspectRatio ?? 1.78)) *
(trickplayInfo.data?.TileHeight ?? 1),
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit='cover'
/>
</View>
{/* Time overlay */}
<View
style={{
position: "absolute",
bottom: 4,
left: 4,
backgroundColor: "rgba(0, 0, 0, 0.7)",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
}}
>
<Text
style={{
color: "#fff",
fontSize: 12,
fontWeight: "600",
}}
>
{`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${
trickplayTime.minutes < 10
? `0${trickplayTime.minutes}`
: trickplayTime.minutes
}:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`}
</Text>
</View>
</View>
);
}}
sliderHeight={6}
thumbWidth={16}
panHitSlop={{ top: 30, bottom: 30, left: 10, right: 10 }}
/>
</View>
{/* Time display */}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 24,
}}
>
<Text style={{ color: "#999", fontSize: 13 }}>
{formatTime(progress * 1000)}
</Text>
<Text style={{ color: "#999", fontSize: 13 }}>
Ending at{" "}
{calculateEndingTime(progress * 1000, duration * 1000)}
</Text>
<Text style={{ color: "#999", fontSize: 13 }}>
{formatTime(duration * 1000)}
</Text>
</View>
{/* Playback controls */}
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 32,
marginBottom: 24,
}}
>
{/* Rewind (use settings) */}
<Pressable
onPress={() => skipBackward(settings?.rewindSkipTime ?? 10)}
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='refresh-outline'
size={48}
color='white'
style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }}
/>
{settings?.rewindSkipTime && (
<Text
style={{
position: "absolute",
color: "white",
fontSize: 15,
fontWeight: "bold",
bottom: 10,
}}
>
{settings.rewindSkipTime}
</Text>
)}
</Pressable>
{/* Play/Pause */}
<Pressable
onPress={togglePlayPause}
style={{
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: protocolColor,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={36}
color='white'
style={{ marginLeft: isPlaying ? 0 : 4 }}
/>
</Pressable>
{/* Forward (use settings) */}
<Pressable
onPress={() => skipForward(settings?.forwardSkipTime ?? 10)}
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='refresh-outline' size={48} color='white' />
{settings?.forwardSkipTime && (
<Text
style={{
position: "absolute",
color: "white",
fontSize: 15,
fontWeight: "bold",
bottom: 10,
}}
>
{settings.forwardSkipTime}
</Text>
)}
</Pressable>
</View>
</View>
{/* Modals */}
<ChromecastDeviceSheet
visible={showDeviceSheet && protocol === "chromecast"}
onClose={() => setShowDeviceSheet(false)}
device={
currentDevice && protocol === "chromecast" && castDevice
? ({
deviceId: castDevice.deviceId,
friendlyName: currentDevice,
} as any)
: null
}
onDisconnect={async () => {
try {
// End the casting session and disconnect completely
const sessionManager = GoogleCast.getSessionManager();
await sessionManager.endCurrentSession(true);
setShowDeviceSheet(false);
// Close player immediately after disconnecting
setTimeout(() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}, 100);
} catch (error) {
console.error(
"[Casting Player] Error disconnecting from Chromecast:",
error,
);
}
}}
volume={volume}
onVolumeChange={async (vol) => {
setVolume(vol);
}}
/>
<ChromecastEpisodeList
visible={showEpisodeList}
onClose={() => setShowEpisodeList(false)}
currentItem={currentItem}
episodes={episodes}
api={api}
onSelectEpisode={(episode) => {
// TODO: Load new episode - requires casting new media
console.log("Selected episode:", episode.Name);
setShowEpisodeList(false);
}}
/>
<ChromecastSettingsMenu
visible={showSettings}
onClose={() => setShowSettings(false)}
item={currentItem}
mediaSources={availableMediaSources.filter((source) => {
const currentBitrate =
availableMediaSources[0]?.bitrate || Number.POSITIVE_INFINITY;
return (source.bitrate || 0) <= currentBitrate;
})}
selectedMediaSource={availableMediaSources[0] || null}
onMediaSourceChange={(source) => {
// Reload stream with new bitrate
console.log("Changed media source:", source);
reloadWithSettings({ bitrateValue: source.bitrate });
}}
audioTracks={availableAudioTracks}
selectedAudioTrack={
selectedAudioTrackIndex !== null
? availableAudioTracks.find(
(t) => t.index === selectedAudioTrackIndex,
) || null
: availableAudioTracks[0] || null
}
onAudioTrackChange={(track) => {
setSelectedAudioTrackIndex(track.index);
// Reload stream with new audio track
reloadWithSettings({ audioIndex: track.index });
}}
subtitleTracks={availableSubtitleTracks}
selectedSubtitleTrack={
selectedSubtitleTrackIndex !== null
? availableSubtitleTracks.find(
(t) => t.index === selectedSubtitleTrackIndex,
) || null
: null
}
onSubtitleTrackChange={(track) => {
setSelectedSubtitleTrackIndex(track?.index ?? null);
// Reload stream with new subtitle track
reloadWithSettings({ subtitleIndex: track?.index ?? null });
}}
playbackSpeed={currentPlaybackSpeed}
onPlaybackSpeedChange={(speed) => {
setCurrentPlaybackSpeed(speed);
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
}}
/>
</Animated.View>
</GestureDetector>
</>
);
}