From 01ef066a3d61ff6f4bc05649407497a9e06155a1 Mon Sep 17 00:00:00 2001 From: Uruk Date: Fri, 23 Jan 2026 15:46:03 +0100 Subject: [PATCH] feat: Enhances casting player with API data Enriches the casting player screen by fetching item details from the Jellyfin API for a more reliable and complete user experience. The casting player now prioritizes item data fetched directly from the API, providing richer metadata and ensuring accurate information display. - Fetches full item data based on content ID. - Uses fetched data as the primary source of item information, falling back to customData or minimal info if unavailable. - Improves UI by showing connection quality and bitrate. - Enhances episode list display and scrolling. - Adds a stop casting button. - Minor UI adjustments for better readability and aesthetics. This change enhances the accuracy and reliability of displayed information, improving the overall user experience of the casting player. --- app/(auth)/casting-player.tsx | 416 ++++++++---------- components/PlayButton.tsx | 15 + components/casting/CastingMiniPlayer.tsx | 10 +- .../chromecast/ChromecastDeviceSheet.tsx | 70 ++- .../chromecast/ChromecastEpisodeList.tsx | 144 +++--- .../chromecast/ChromecastSettingsMenu.tsx | 4 +- .../controls/TechnicalInfoOverlay.tsx | 8 +- hooks/useCasting.ts | 19 +- translations/en.json | 5 - 9 files changed, 395 insertions(+), 296 deletions(-) diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 7259dfa1..563a5482 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -62,6 +62,40 @@ export default function CastingPlayerScreen() { // Live progress tracking - update every second const [liveProgress, setLiveProgress] = useState(0); + // Fetch full item data from Jellyfin by ID + const [fetchedItem, setFetchedItem] = useState(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) { @@ -84,26 +118,41 @@ export default function CastingPlayerScreen() { return () => clearInterval(interval); }, [mediaStatus?.playerState, mediaStatus?.streamPosition]); - // Extract item from customData, or create a minimal item from mediaInfo + // 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; + } - // If we have full item data in customData, use it - if (customData) return customData; - - // Otherwise, create a minimal item from available mediaInfo + // 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", // Default type + Type: "Movie", // Temporary until API fetch completes ServerId: "", } as BaseItemDto; } return null; - }, [mediaStatus?.mediaInfo]); + }, [fetchedItem, mediaStatus?.mediaInfo]); // Derive state from raw Chromecast hooks const protocol: CastProtocol = "chromecast"; @@ -329,12 +378,13 @@ export default function CastingPlayerScreen() { const isSeeking = useSharedValue(false); const dismissModal = useCallback(() => { - // Reset animation before dismissing to prevent black screen - translateY.value = 0; + // Navigate immediately without animation to prevent crashes if (router.canGoBack()) { router.back(); + } else { + router.replace("/(auth)/(tabs)/(home)/"); } - }, [translateY]); + }, [router]); const panGesture = Gesture.Pan() .onStart(() => { @@ -383,17 +433,27 @@ export default function CastingPlayerScreen() { progressGestureContext.value.startValue + deltaSeconds, ), ); - // Update live progress for immediate UI feedback - setLiveProgress(newPosition); + // Update live progress for immediate UI feedback (must use runOnJS) + runOnJS(setLiveProgress)(newPosition); }) - .onEnd(() => { + .onEnd((event) => { isSeeking.value = false; - // Seek to final position - if (remoteMediaClient) { - const finalPosition = Math.max(0, Math.min(duration, liveProgress)); - remoteMediaClient.seek({ position: finalPosition }).catch((error) => { - console.error("[Casting Player] Seek error:", error); - }); + // Calculate final position from gesture context + if (remoteMediaClient && duration) { + const deltaSeconds = event.translationX / 5; + const finalPosition = Math.max( + 0, + Math.min( + duration, + progressGestureContext.value.startValue + deltaSeconds, + ), + ); + // Use runOnJS to call the async function + runOnJS((pos: number) => { + remoteMediaClient.seek({ position: pos }).catch((error) => { + console.error("[Casting Player] Seek error:", error); + }); + })(finalPosition); } }); @@ -471,7 +531,7 @@ export default function CastingPlayerScreen() { } }, [connectionQuality, protocolColor]); - const showNextEpisode = useMemo(() => { + const _showNextEpisode = useMemo(() => { if (currentItem?.Type !== "Episode" || !nextEpisode) return false; const remaining = duration - progress; return shouldShowNextEpisodeCountdown(remaining, true, 30); @@ -617,54 +677,66 @@ export default function CastingPlayerScreen() { + {/* Title Area */} + + {/* Title */} + + {truncateTitle(currentItem.Name || "Unknown", 50)} + + + {/* Grey episode/season info */} + {currentItem.Type === "Episode" && + currentItem.ParentIndexNumber !== undefined && + currentItem.IndexNumber !== undefined && ( + + {t("casting_player.season_episode_format", { + season: currentItem.ParentIndexNumber, + episode: currentItem.IndexNumber, + })} + + )} + + {/* Scrollable content area */} - {/* Title */} - - - {truncateTitle(currentItem.Name || "Unknown", 50)} - - - - {/* Grey episode/season info between title and poster */} - {currentItem.Type === "Episode" && - currentItem.ParentIndexNumber !== undefined && - currentItem.IndexNumber !== undefined && ( - - - {t("casting_player.season_episode_format", { - season: currentItem.ParentIndexNumber, - episode: currentItem.IndexNumber, - })} - - - )} - {/* Poster with buffering overlay - reduced size */} @@ -789,16 +860,19 @@ export default function CastingPlayerScreen() { + {/* Spacer to push buttons down */} + + {/* 4-button control row for episodes */} - {currentItem.Type === "Episode" && episodes.length > 0 && ( + {currentItem.Type === "Episode" && ( {/* Episodes button */} @@ -812,66 +886,9 @@ export default function CastingPlayerScreen() { flexDirection: "row", justifyContent: "center", alignItems: "center", - gap: 6, }} > - - - {t("casting_player.episodes")} - - - - {/* Favorite button */} - { - if (!api || !user?.Id || !currentItem.Id) return; - try { - const newIsFavorite = !( - currentItem.UserData?.IsFavorite ?? false - ); - const path = `/Users/${user.Id}/FavoriteItems/${currentItem.Id}`; - - if (newIsFavorite) { - await api.post(path, {}, {}); - } else { - await api.delete(path, {}); - } - - // Update local state - if (currentItem.UserData) { - currentItem.UserData.IsFavorite = newIsFavorite; - } - } catch (error) { - console.error("Failed to toggle favorite:", error); - } - }} - style={{ - flex: 1, - backgroundColor: "#1a1a1a", - padding: 12, - borderRadius: 12, - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - gap: 6, - }} - > - - - {t("casting_player.favorite")} - + {/* Previous episode button */} @@ -896,19 +913,13 @@ export default function CastingPlayerScreen() { flexDirection: "row", justifyContent: "center", alignItems: "center", - gap: 6, opacity: episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0 ? 0.4 : 1, }} > - - - {t("casting_player.previous")} - + {/* Next episode button */} @@ -927,20 +938,57 @@ export default function CastingPlayerScreen() { flexDirection: "row", justifyContent: "center", alignItems: "center", - gap: 6, opacity: nextEpisode ? 1 : 0.4, }} > - - - {t("casting_player.next")} - + + + + {/* Stop casting button */} + { + try { + await stop(); + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(auth)/(tabs)/(home)/"); + } + } catch (error) { + console.error("[Casting Player] Error stopping:", error); + 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", + }} + > + )} + + {/* Fixed bottom controls area */} + {/* Progress slider - interactive with pan gesture and tap */} @@ -1003,47 +1051,6 @@ export default function CastingPlayerScreen() { - {/* Next episode countdown */} - {showNextEpisode && nextEpisode && ( - - - - - - {t("player.next_episode")}: {nextEpisode.Name} - - - Starting in {Math.ceil((duration - progress) / 1000)}s - - - { - setNextEpisode(null); // Cancel auto-play - }} - style={{ marginLeft: 8 }} - > - - - - - )} - {/* Playback controls */} - - - {/* Fixed End Playback button at bottom */} - - { - try { - await stop(); - if (router.canGoBack()) { - router.back(); - } else { - router.replace("/(auth)/(tabs)/(home)/"); - } - } catch (error) { - console.error("[Casting Player] Error stopping:", error); - if (router.canGoBack()) { - router.back(); - } else { - router.replace("/(auth)/(tabs)/(home)/"); - } - } - }} - style={{ - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - gap: 8, - paddingVertical: 16, - paddingHorizontal: 24, - borderRadius: 12, - backgroundColor: "#1a1a1a", - borderWidth: 2, - borderColor: "#FF3B30", - }} - > - - - {t("casting_player.end_playback")} - - {/* Modals */} @@ -1193,6 +1150,8 @@ export default function CastingPlayerScreen() { } as any) : null } + connectionQuality={connectionQuality} + bitrate={availableMediaSources[0]?.bitrate} onDisconnect={async () => { try { await stop(); @@ -1221,6 +1180,7 @@ export default function CastingPlayerScreen() { 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); @@ -1232,7 +1192,11 @@ export default function CastingPlayerScreen() { visible={showSettings} onClose={() => setShowSettings(false)} item={currentItem} - mediaSources={availableMediaSources} + mediaSources={availableMediaSources.filter((source) => { + const currentBitrate = + availableMediaSources[0]?.bitrate || Number.POSITIVE_INFINITY; + return (source.bitrate || 0) <= currentBitrate; + })} selectedMediaSource={availableMediaSources[0] || null} onMediaSourceChange={(source) => { // TODO: Requires reloading media with new source URL diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 52c8cde7..084ce942 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -176,6 +176,15 @@ export const PlayButton: React.FC = ({ }); console.log("URL: ", data?.url, enableH265); + console.log("[PlayButton] Item before casting:", { + Type: item.Type, + Id: item.Id, + Name: item.Name, + ParentIndexNumber: item.ParentIndexNumber, + IndexNumber: item.IndexNumber, + SeasonId: item.SeasonId, + SeriesId: item.SeriesId, + }); if (!data?.url) { console.warn("No URL returned from getStreamUrl", data); @@ -195,6 +204,11 @@ export const PlayButton: React.FC = ({ ? item.RunTimeTicks / 10000000 : undefined; + console.log("[PlayButton] Loading media with customData:", { + hasCustomData: !!item, + customDataType: item.Type, + }); + client .loadMedia({ mediaInfo: { @@ -203,6 +217,7 @@ export const PlayButton: React.FC = ({ contentType: "video/mp4", streamType: MediaStreamType.BUFFERED, streamDuration: streamDurationSeconds, + customData: item, metadata: item.Type === "Episode" ? { diff --git a/components/casting/CastingMiniPlayer.tsx b/components/casting/CastingMiniPlayer.tsx index 87f8558f..f1b7b33f 100644 --- a/components/casting/CastingMiniPlayer.tsx +++ b/components/casting/CastingMiniPlayer.tsx @@ -10,6 +10,7 @@ import { useAtomValue } from "jotai"; import React from "react"; import { Pressable, View } from "react-native"; import Animated, { SlideInDown, SlideOutDown } 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"; @@ -23,6 +24,7 @@ import { CASTING_CONSTANTS } from "@/utils/casting/types"; export const CastingMiniPlayer: React.FC = () => { const api = useAtomValue(apiAtom); + const insets = useSafeAreaInsets(); const { isConnected, protocol, @@ -48,6 +50,7 @@ export const CastingMiniPlayer: React.FC = () => { const progressPercent = duration > 0 ? (progress / duration) * 100 : 0; const protocolColor = "#a855f7"; // Streamyfin purple + const TAB_BAR_HEIGHT = 49; // Standard tab bar height const handlePress = () => { router.push("/casting-player"); @@ -59,12 +62,13 @@ export const CastingMiniPlayer: React.FC = () => { exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)} style={{ position: "absolute", - bottom: 49, // Above tab bar + bottom: TAB_BAR_HEIGHT + insets.bottom, left: 0, right: 0, backgroundColor: "#1a1a1a", borderTopWidth: 1, borderTopColor: "#333", + zIndex: 100, }} > @@ -166,7 +170,9 @@ export const CastingMiniPlayer: React.FC = () => { { e.stopPropagation(); - togglePlayPause(); + if (isConnected && protocol) { + togglePlayPause(); + } }} style={{ padding: 8, diff --git a/components/chromecast/ChromecastDeviceSheet.tsx b/components/chromecast/ChromecastDeviceSheet.tsx index 0747cffd..f407014c 100644 --- a/components/chromecast/ChromecastDeviceSheet.tsx +++ b/components/chromecast/ChromecastDeviceSheet.tsx @@ -20,6 +20,8 @@ interface ChromecastDeviceSheetProps { volume?: number; onVolumeChange?: (volume: number) => Promise; showTechnicalInfo?: boolean; + connectionQuality?: "excellent" | "good" | "fair" | "poor"; + bitrate?: number; } export const ChromecastDeviceSheet: React.FC = ({ @@ -30,6 +32,8 @@ export const ChromecastDeviceSheet: React.FC = ({ volume = 0.5, onVolumeChange, showTechnicalInfo = false, + connectionQuality = "good", + bitrate, }) => { const insets = useSafeAreaInsets(); const [isDisconnecting, setIsDisconnecting] = useState(false); @@ -61,14 +65,14 @@ export const ChromecastDeviceSheet: React.FC = ({ return ( = ({ + {/* Connection Quality */} + + + Connection Quality + + + + + {connectionQuality} + + + {bitrate && ( + + Bitrate: {(bitrate / 1000000).toFixed(1)} Mbps + {connectionQuality === "poor" && + " (Low bitrate may cause buffering)"} + {connectionQuality === "fair" && " (Moderate quality)"} + {connectionQuality === "good" && " (Good quality)"} + {connectionQuality === "excellent" && " (Maximum quality)"} + + )} + + {device?.deviceId && showTechnicalInfo && ( @@ -162,10 +219,19 @@ export const ChromecastDeviceSheet: React.FC = ({ minimumTrackTintColor: "#a855f7", bubbleBackgroundColor: "#a855f7", }} + onSlidingStart={() => { + console.log( + "[Volume] Sliding started", + volumeValue.value, + ); + }} onValueChange={(value) => { volumeValue.value = value; + console.log("[Volume] Value changed", value); }} onSlidingComplete={handleVolumeComplete} + panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }} + disable={false} /> diff --git a/components/chromecast/ChromecastEpisodeList.tsx b/components/chromecast/ChromecastEpisodeList.tsx index 1a14453d..eb2e056a 100644 --- a/components/chromecast/ChromecastEpisodeList.tsx +++ b/components/chromecast/ChromecastEpisodeList.tsx @@ -4,13 +4,15 @@ */ import { Ionicons } from "@expo/vector-icons"; +import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; -import React from "react"; +import React, { useEffect, useRef } 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/casting/helpers"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; interface ChromecastEpisodeListProps { visible: boolean; @@ -18,6 +20,7 @@ interface ChromecastEpisodeListProps { currentItem: BaseItemDto | null; episodes: BaseItemDto[]; onSelectEpisode: (episode: BaseItemDto) => void; + api: Api | null; } export const ChromecastEpisodeList: React.FC = ({ @@ -26,8 +29,26 @@ export const ChromecastEpisodeList: React.FC = ({ currentItem, episodes, onSelectEpisode, + api, }) => { const insets = useSafeAreaInsets(); + const flatListRef = useRef(null); + + useEffect(() => { + if (visible && currentItem && episodes.length > 0) { + const currentIndex = episodes.findIndex((ep) => ep.Id === currentItem.Id); + if (currentIndex !== -1 && flatListRef.current) { + // Delay to ensure FlatList is rendered + setTimeout(() => { + flatListRef.current?.scrollToIndex({ + index: currentIndex, + animated: true, + viewPosition: 0.5, // Center the item + }); + }, 300); + } + } + }, [visible, currentItem, episodes]); const renderEpisode = ({ item }: { item: BaseItemDto }) => { const isCurrentEpisode = item.Id === currentItem?.Id; @@ -56,16 +77,16 @@ export const ChromecastEpisodeList: React.FC = ({ backgroundColor: "#1a1a1a", }} > - {item.ImageTags?.Primary && ( + {api && item.Id && ( )} - {!item.ImageTags?.Primary && ( + {(!api || !item.Id) && ( = ({ > {item.IndexNumber}. {truncateTitle(item.Name || "Unknown", 30)} - - {item.Overview || "No description available"} - - {item.RunTimeTicks && ( + {item.Overview && ( - {Math.round((item.RunTimeTicks / 600000000) * 10) / 10} min + {item.Overview} )} + + {item.ProductionYear && ( + + {item.ProductionYear} + + )} + {item.RunTimeTicks && ( + + {Math.round(item.RunTimeTicks / 600000000)} min + + )} + {isCurrentEpisode && ( @@ -130,49 +155,68 @@ export const ChromecastEpisodeList: React.FC = ({ return ( - - {/* Header */} - e.stopPropagation()} > - - Episodes - - - - - + {/* Header */} + + + Episodes + + + + + - {/* Episode list */} - item.Id || ""} - contentContainerStyle={{ - padding: 16, - paddingBottom: insets.bottom + 16, - }} - showsVerticalScrollIndicator={false} - /> - + {/* Episode list */} + item.Id || ""} + contentContainerStyle={{ + padding: 16, + paddingBottom: insets.bottom + 16, + }} + showsVerticalScrollIndicator={false} + onScrollToIndexFailed={(info) => { + // Fallback if scroll fails + setTimeout(() => { + flatListRef.current?.scrollToIndex({ + index: info.index, + animated: true, + viewPosition: 0.5, + }); + }, 500); + }} + /> + + ); }; diff --git a/components/chromecast/ChromecastSettingsMenu.tsx b/components/chromecast/ChromecastSettingsMenu.tsx index c13eeedb..ccd70300 100644 --- a/components/chromecast/ChromecastSettingsMenu.tsx +++ b/components/chromecast/ChromecastSettingsMenu.tsx @@ -94,14 +94,14 @@ export const ChromecastSettingsMenu: React.FC = ({ return ( { }; export const TechnicalInfoOverlay: FC = memo( - ({ - showControls, - visible, - getTechnicalInfo, - playMethod, - transcodeReasons, - }) => { + ({ visible, getTechnicalInfo, playMethod, transcodeReasons }) => { const { settings } = useSettings(); const insets = useSafeAreaInsets(); const [info, setInfo] = useState(null); diff --git a/hooks/useCasting.ts b/hooks/useCasting.ts index aeed6d14..5f2552eb 100644 --- a/hooks/useCasting.ts +++ b/hooks/useCasting.ts @@ -85,6 +85,16 @@ export const useCasting = (item: BaseItemDto | null) => { } }, [mediaStatus, activeProtocol]); + // Chromecast: Sync volume from device + useEffect(() => { + if (activeProtocol === "chromecast" && mediaStatus?.volume !== undefined) { + setState((prev) => ({ + ...prev, + volume: mediaStatus.volume, + })); + } + }, [mediaStatus?.volume, activeProtocol]); + // Progress reporting to Jellyfin (optimized to skip redundant reports) useEffect(() => { if (!isConnected || !item?.Id || !user?.Id || state.progress <= 0) return; @@ -209,8 +219,13 @@ export const useCasting = (item: BaseItemDto | null) => { } volumeDebounceRef.current = setTimeout(async () => { - if (activeProtocol === "chromecast") { - await client?.setStreamVolume(clampedVolume).catch(console.error); + if (activeProtocol === "chromecast" && client && isConnected) { + await client.setStreamVolume(clampedVolume).catch((error) => { + console.log( + "[useCasting] Volume set failed (no session):", + error.message, + ); + }); } // Future: Add volume control for other protocols }, 300); diff --git a/translations/en.json b/translations/en.json index b7d14644..4f57aa99 100644 --- a/translations/en.json +++ b/translations/en.json @@ -51,11 +51,6 @@ }, "casting_player": { "buffering": "Buffering...", - "episodes": "Episodes", - "favorite": "Favorite", - "next": "Next", - "previous": "Previous", - "end_playback": "End Playback", "season_episode_format": "Season {{season}} • Episode {{episode}}", "connection_quality": { "excellent": "Excellent",