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.
This commit is contained in:
Uruk
2026-01-23 15:46:03 +01:00
parent 4ad07d22bd
commit bc08df903f
9 changed files with 395 additions and 296 deletions

View File

@@ -62,6 +62,40 @@ export default function CastingPlayerScreen() {
// Live progress tracking - update every second // Live progress tracking - update every second
const [liveProgress, setLiveProgress] = useState(0); 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(() => { useEffect(() => {
// Initialize with actual position // Initialize with actual position
if (mediaStatus?.streamPosition) { if (mediaStatus?.streamPosition) {
@@ -84,26 +118,41 @@ export default function CastingPlayerScreen() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]); }, [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(() => { 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; 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 // Priority 3: Create minimal fallback while loading
if (customData) return customData;
// Otherwise, create a minimal item from available mediaInfo
if (mediaStatus?.mediaInfo) { if (mediaStatus?.mediaInfo) {
const { contentId, metadata } = mediaStatus.mediaInfo; const { contentId, metadata } = mediaStatus.mediaInfo;
console.log(
"[Casting Player] Using minimal fallback item (still loading)",
);
return { return {
Id: contentId, Id: contentId,
Name: metadata?.title || "Unknown", Name: metadata?.title || "Unknown",
Type: "Movie", // Default type Type: "Movie", // Temporary until API fetch completes
ServerId: "", ServerId: "",
} as BaseItemDto; } as BaseItemDto;
} }
return null; return null;
}, [mediaStatus?.mediaInfo]); }, [fetchedItem, mediaStatus?.mediaInfo]);
// Derive state from raw Chromecast hooks // Derive state from raw Chromecast hooks
const protocol: CastProtocol = "chromecast"; const protocol: CastProtocol = "chromecast";
@@ -329,12 +378,13 @@ export default function CastingPlayerScreen() {
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const dismissModal = useCallback(() => { const dismissModal = useCallback(() => {
// Reset animation before dismissing to prevent black screen // Navigate immediately without animation to prevent crashes
translateY.value = 0;
if (router.canGoBack()) { if (router.canGoBack()) {
router.back(); router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
} }
}, [translateY]); }, [router]);
const panGesture = Gesture.Pan() const panGesture = Gesture.Pan()
.onStart(() => { .onStart(() => {
@@ -383,17 +433,27 @@ export default function CastingPlayerScreen() {
progressGestureContext.value.startValue + deltaSeconds, progressGestureContext.value.startValue + deltaSeconds,
), ),
); );
// Update live progress for immediate UI feedback // Update live progress for immediate UI feedback (must use runOnJS)
setLiveProgress(newPosition); runOnJS(setLiveProgress)(newPosition);
}) })
.onEnd(() => { .onEnd((event) => {
isSeeking.value = false; isSeeking.value = false;
// Seek to final position // Calculate final position from gesture context
if (remoteMediaClient) { if (remoteMediaClient && duration) {
const finalPosition = Math.max(0, Math.min(duration, liveProgress)); const deltaSeconds = event.translationX / 5;
remoteMediaClient.seek({ position: finalPosition }).catch((error) => { const finalPosition = Math.max(
console.error("[Casting Player] Seek error:", error); 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]); }, [connectionQuality, protocolColor]);
const showNextEpisode = useMemo(() => { const _showNextEpisode = useMemo(() => {
if (currentItem?.Type !== "Episode" || !nextEpisode) return false; if (currentItem?.Type !== "Episode" || !nextEpisode) return false;
const remaining = duration - progress; const remaining = duration - progress;
return shouldShowNextEpisodeCountdown(remaining, true, 30); return shouldShowNextEpisodeCountdown(remaining, true, 30);
@@ -617,54 +677,66 @@ export default function CastingPlayerScreen() {
</Pressable> </Pressable>
</View> </View>
{/* Title Area */}
<View
style={{
position: "absolute",
top: insets.top + 40,
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: 22,
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: 14,
textAlign: "center",
}}
>
{t("casting_player.season_episode_format", {
season: currentItem.ParentIndexNumber,
episode: currentItem.IndexNumber,
})}
</Text>
)}
</View>
{/* Scrollable content area */} {/* Scrollable content area */}
<ScrollView <ScrollView
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: insets.top + 60, paddingTop: insets.top + 140,
paddingBottom: insets.bottom + 300, paddingBottom: insets.bottom + 500,
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Title */}
<View style={{ marginBottom: 8 }}>
<Text
style={{
color: "white",
fontSize: 28,
fontWeight: "700",
textAlign: "center",
}}
>
{truncateTitle(currentItem.Name || "Unknown", 50)}
</Text>
</View>
{/* Grey episode/season info between title and poster */}
{currentItem.Type === "Episode" &&
currentItem.ParentIndexNumber !== undefined &&
currentItem.IndexNumber !== undefined && (
<View style={{ marginBottom: 20 }}>
<Text
style={{
color: "#999",
fontSize: 16,
textAlign: "center",
}}
>
{t("casting_player.season_episode_format", {
season: currentItem.ParentIndexNumber,
episode: currentItem.IndexNumber,
})}
</Text>
</View>
)}
{/* Poster with buffering overlay - reduced size */} {/* Poster with buffering overlay - reduced size */}
<View <View
style={{ style={{
alignItems: "center", alignItems: "center",
marginBottom: 32, marginBottom: 40,
}} }}
> >
<View <View
@@ -771,7 +843,6 @@ export default function CastingPlayerScreen() {
backgroundColor: "rgba(0,0,0,0.7)", backgroundColor: "rgba(0,0,0,0.7)",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
backdropFilter: "blur(10px)",
}} }}
> >
<ActivityIndicator size='large' color={protocolColor} /> <ActivityIndicator size='large' color={protocolColor} />
@@ -789,16 +860,19 @@ export default function CastingPlayerScreen() {
</View> </View>
</View> </View>
{/* Spacer to push buttons down */}
<View style={{ height: 40 }} />
{/* 4-button control row for episodes */} {/* 4-button control row for episodes */}
{currentItem.Type === "Episode" && episodes.length > 0 && ( {currentItem.Type === "Episode" && (
<View <View
style={{ style={{
flexDirection: "row", flexDirection: "row",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
gap: 12, gap: 16,
marginBottom: 20, marginBottom: 40,
paddingHorizontal: 16, paddingHorizontal: 20,
}} }}
> >
{/* Episodes button */} {/* Episodes button */}
@@ -812,66 +886,9 @@ export default function CastingPlayerScreen() {
flexDirection: "row", flexDirection: "row",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
gap: 6,
}} }}
> >
<Ionicons name='list' size={18} color='white' /> <Ionicons name='list' size={22} color='white' />
<Text
style={{ color: "white", fontSize: 13, fontWeight: "600" }}
>
{t("casting_player.episodes")}
</Text>
</Pressable>
{/* Favorite button */}
<Pressable
onPress={async () => {
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,
}}
>
<Ionicons
name={
currentItem.UserData?.IsFavorite
? "heart"
: "heart-outline"
}
size={18}
color='white'
/>
<Text
style={{ color: "white", fontSize: 13, fontWeight: "600" }}
>
{t("casting_player.favorite")}
</Text>
</Pressable> </Pressable>
{/* Previous episode button */} {/* Previous episode button */}
@@ -896,19 +913,13 @@ export default function CastingPlayerScreen() {
flexDirection: "row", flexDirection: "row",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
gap: 6,
opacity: opacity:
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0 episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
? 0.4 ? 0.4
: 1, : 1,
}} }}
> >
<Ionicons name='play-skip-back' size={18} color='white' /> <Ionicons name='play-skip-back' size={22} color='white' />
<Text
style={{ color: "white", fontSize: 13, fontWeight: "600" }}
>
{t("casting_player.previous")}
</Text>
</Pressable> </Pressable>
{/* Next episode button */} {/* Next episode button */}
@@ -927,20 +938,57 @@ export default function CastingPlayerScreen() {
flexDirection: "row", flexDirection: "row",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
gap: 6,
opacity: nextEpisode ? 1 : 0.4, opacity: nextEpisode ? 1 : 0.4,
}} }}
> >
<Ionicons name='play-skip-forward' size={18} color='white' /> <Ionicons name='play-skip-forward' size={22} color='white' />
<Text </Pressable>
style={{ color: "white", fontSize: 13, fontWeight: "600" }}
> {/* Stop casting button */}
{t("casting_player.next")} <Pressable
</Text> onPress={async () => {
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",
}}
>
<Ionicons name='stop-circle' size={22} color='white' />
</Pressable> </Pressable>
</View> </View>
)} )}
</ScrollView>
{/* Fixed bottom controls area */}
<View
style={{
position: "absolute",
bottom: insets.bottom + 10,
left: 20,
right: 20,
zIndex: 98,
}}
>
{/* Progress slider - interactive with pan gesture and tap */} {/* Progress slider - interactive with pan gesture and tap */}
<View style={{ marginBottom: 16, marginTop: 8 }}> <View style={{ marginBottom: 16, marginTop: 8 }}>
<GestureDetector gesture={progressPanGesture}> <GestureDetector gesture={progressPanGesture}>
@@ -1003,47 +1051,6 @@ export default function CastingPlayerScreen() {
</Text> </Text>
</View> </View>
{/* Next episode countdown */}
{showNextEpisode && nextEpisode && (
<View style={{ marginBottom: 24, alignItems: "center" }}>
<View
style={{
backgroundColor: "#1a1a1a",
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
flexDirection: "row",
alignItems: "center",
gap: 12,
}}
>
<ActivityIndicator size='small' color={protocolColor} />
<View style={{ flex: 1 }}>
<Text
style={{
color: "white",
fontSize: 14,
fontWeight: "600",
}}
>
{t("player.next_episode")}: {nextEpisode.Name}
</Text>
<Text style={{ color: "#999", fontSize: 12, marginTop: 2 }}>
Starting in {Math.ceil((duration - progress) / 1000)}s
</Text>
</View>
<Pressable
onPress={() => {
setNextEpisode(null); // Cancel auto-play
}}
style={{ marginLeft: 8 }}
>
<Ionicons name='close-circle' size={24} color='#999' />
</Pressable>
</View>
</View>
)}
{/* Playback controls */} {/* Playback controls */}
<View <View
style={{ style={{
@@ -1129,56 +1136,6 @@ export default function CastingPlayerScreen() {
)} )}
</Pressable> </Pressable>
</View> </View>
</ScrollView>
{/* Fixed End Playback button at bottom */}
<View
style={{
position: "absolute",
bottom: insets.bottom + 16,
left: 20,
right: 20,
zIndex: 99,
}}
>
<Pressable
onPress={async () => {
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",
}}
>
<Ionicons name='stop-circle-outline' size={22} color='#FF3B30' />
<Text
style={{ color: "#FF3B30", fontSize: 17, fontWeight: "700" }}
>
{t("casting_player.end_playback")}
</Text>
</Pressable>
</View> </View>
{/* Modals */} {/* Modals */}
@@ -1193,6 +1150,8 @@ export default function CastingPlayerScreen() {
} as any) } as any)
: null : null
} }
connectionQuality={connectionQuality}
bitrate={availableMediaSources[0]?.bitrate}
onDisconnect={async () => { onDisconnect={async () => {
try { try {
await stop(); await stop();
@@ -1221,6 +1180,7 @@ export default function CastingPlayerScreen() {
onClose={() => setShowEpisodeList(false)} onClose={() => setShowEpisodeList(false)}
currentItem={currentItem} currentItem={currentItem}
episodes={episodes} episodes={episodes}
api={api}
onSelectEpisode={(episode) => { onSelectEpisode={(episode) => {
// TODO: Load new episode - requires casting new media // TODO: Load new episode - requires casting new media
console.log("Selected episode:", episode.Name); console.log("Selected episode:", episode.Name);
@@ -1232,7 +1192,11 @@ export default function CastingPlayerScreen() {
visible={showSettings} visible={showSettings}
onClose={() => setShowSettings(false)} onClose={() => setShowSettings(false)}
item={currentItem} 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} selectedMediaSource={availableMediaSources[0] || null}
onMediaSourceChange={(source) => { onMediaSourceChange={(source) => {
// TODO: Requires reloading media with new source URL // TODO: Requires reloading media with new source URL

View File

@@ -176,6 +176,15 @@ export const PlayButton: React.FC<Props> = ({
}); });
console.log("URL: ", data?.url, enableH265); 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) { if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data); console.warn("No URL returned from getStreamUrl", data);
@@ -195,6 +204,11 @@ export const PlayButton: React.FC<Props> = ({
? item.RunTimeTicks / 10000000 ? item.RunTimeTicks / 10000000
: undefined; : undefined;
console.log("[PlayButton] Loading media with customData:", {
hasCustomData: !!item,
customDataType: item.Type,
});
client client
.loadMedia({ .loadMedia({
mediaInfo: { mediaInfo: {
@@ -203,6 +217,7 @@ export const PlayButton: React.FC<Props> = ({
contentType: "video/mp4", contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED, streamType: MediaStreamType.BUFFERED,
streamDuration: streamDurationSeconds, streamDuration: streamDurationSeconds,
customData: item,
metadata: metadata:
item.Type === "Episode" item.Type === "Episode"
? { ? {

View File

@@ -10,6 +10,7 @@ import { useAtomValue } from "jotai";
import React from "react"; import React from "react";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated"; import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useCasting } from "@/hooks/useCasting"; import { useCasting } from "@/hooks/useCasting";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
@@ -23,6 +24,7 @@ import { CASTING_CONSTANTS } from "@/utils/casting/types";
export const CastingMiniPlayer: React.FC = () => { export const CastingMiniPlayer: React.FC = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets();
const { const {
isConnected, isConnected,
protocol, protocol,
@@ -48,6 +50,7 @@ export const CastingMiniPlayer: React.FC = () => {
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0; const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
const protocolColor = "#a855f7"; // Streamyfin purple const protocolColor = "#a855f7"; // Streamyfin purple
const TAB_BAR_HEIGHT = 49; // Standard tab bar height
const handlePress = () => { const handlePress = () => {
router.push("/casting-player"); router.push("/casting-player");
@@ -59,12 +62,13 @@ export const CastingMiniPlayer: React.FC = () => {
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)} exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
style={{ style={{
position: "absolute", position: "absolute",
bottom: 49, // Above tab bar bottom: TAB_BAR_HEIGHT + insets.bottom,
left: 0, left: 0,
right: 0, right: 0,
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: "#333", borderTopColor: "#333",
zIndex: 100,
}} }}
> >
<Pressable onPress={handlePress}> <Pressable onPress={handlePress}>
@@ -166,7 +170,9 @@ export const CastingMiniPlayer: React.FC = () => {
<Pressable <Pressable
onPress={(e) => { onPress={(e) => {
e.stopPropagation(); e.stopPropagation();
togglePlayPause(); if (isConnected && protocol) {
togglePlayPause();
}
}} }}
style={{ style={{
padding: 8, padding: 8,

View File

@@ -20,6 +20,8 @@ interface ChromecastDeviceSheetProps {
volume?: number; volume?: number;
onVolumeChange?: (volume: number) => Promise<void>; onVolumeChange?: (volume: number) => Promise<void>;
showTechnicalInfo?: boolean; showTechnicalInfo?: boolean;
connectionQuality?: "excellent" | "good" | "fair" | "poor";
bitrate?: number;
} }
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
@@ -30,6 +32,8 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
volume = 0.5, volume = 0.5,
onVolumeChange, onVolumeChange,
showTechnicalInfo = false, showTechnicalInfo = false,
connectionQuality = "good",
bitrate,
}) => { }) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [isDisconnecting, setIsDisconnecting] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false);
@@ -61,14 +65,14 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
return ( return (
<Modal <Modal
visible={visible} visible={visible}
transparent={true}
animationType='slide' animationType='slide'
presentationStyle='formSheet'
onRequestClose={onClose} onRequestClose={onClose}
> >
<Pressable <Pressable
style={{ style={{
flex: 1, flex: 1,
backgroundColor: "rgba(0,0,0,0.5)", backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "flex-end", justifyContent: "flex-end",
}} }}
onPress={onClose} onPress={onClose}
@@ -117,6 +121,59 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
</Text> </Text>
</View> </View>
{/* Connection Quality */}
<View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 8 }}>
Connection Quality
</Text>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<View
style={{
width: 12,
height: 12,
borderRadius: 6,
backgroundColor:
connectionQuality === "excellent"
? "#10b981"
: connectionQuality === "good"
? "#fbbf24"
: connectionQuality === "fair"
? "#f97316"
: "#ef4444",
}}
/>
<Text
style={{
color:
connectionQuality === "excellent"
? "#10b981"
: connectionQuality === "good"
? "#fbbf24"
: connectionQuality === "fair"
? "#f97316"
: "#ef4444",
fontSize: 14,
fontWeight: "600",
textTransform: "capitalize",
}}
>
{connectionQuality}
</Text>
</View>
{bitrate && (
<Text style={{ color: "#999", fontSize: 12, marginTop: 4 }}>
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)"}
</Text>
)}
</View>
{device?.deviceId && showTechnicalInfo && ( {device?.deviceId && showTechnicalInfo && (
<View style={{ marginBottom: 20 }}> <View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}> <Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
@@ -162,10 +219,19 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
minimumTrackTintColor: "#a855f7", minimumTrackTintColor: "#a855f7",
bubbleBackgroundColor: "#a855f7", bubbleBackgroundColor: "#a855f7",
}} }}
onSlidingStart={() => {
console.log(
"[Volume] Sliding started",
volumeValue.value,
);
}}
onValueChange={(value) => { onValueChange={(value) => {
volumeValue.value = value; volumeValue.value = value;
console.log("[Volume] Value changed", value);
}} }}
onSlidingComplete={handleVolumeComplete} onSlidingComplete={handleVolumeComplete}
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
disable={false}
/> />
</View> </View>
<Ionicons name='volume-high' size={20} color='#999' /> <Ionicons name='volume-high' size={20} color='#999' />

View File

@@ -4,13 +4,15 @@
*/ */
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import React from "react"; import React, { useEffect, useRef } from "react";
import { FlatList, Modal, Pressable, View } from "react-native"; import { FlatList, Modal, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { truncateTitle } from "@/utils/casting/helpers"; import { truncateTitle } from "@/utils/casting/helpers";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
interface ChromecastEpisodeListProps { interface ChromecastEpisodeListProps {
visible: boolean; visible: boolean;
@@ -18,6 +20,7 @@ interface ChromecastEpisodeListProps {
currentItem: BaseItemDto | null; currentItem: BaseItemDto | null;
episodes: BaseItemDto[]; episodes: BaseItemDto[];
onSelectEpisode: (episode: BaseItemDto) => void; onSelectEpisode: (episode: BaseItemDto) => void;
api: Api | null;
} }
export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
@@ -26,8 +29,26 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
currentItem, currentItem,
episodes, episodes,
onSelectEpisode, onSelectEpisode,
api,
}) => { }) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const flatListRef = useRef<FlatList>(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 renderEpisode = ({ item }: { item: BaseItemDto }) => {
const isCurrentEpisode = item.Id === currentItem?.Id; const isCurrentEpisode = item.Id === currentItem?.Id;
@@ -56,16 +77,16 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
}} }}
> >
{item.ImageTags?.Primary && ( {api && item.Id && (
<Image <Image
source={{ source={{
uri: `${item.Id}/Images/Primary`, uri: getPrimaryImageUrl({ api, item }) || undefined,
}} }}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
contentFit='cover' contentFit='cover'
/> />
)} )}
{!item.ImageTags?.Primary && ( {(!api || !item.Id) && (
<View <View
style={{ style={{
flex: 1, flex: 1,
@@ -91,26 +112,30 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
> >
{item.IndexNumber}. {truncateTitle(item.Name || "Unknown", 30)} {item.IndexNumber}. {truncateTitle(item.Name || "Unknown", 30)}
</Text> </Text>
<Text {item.Overview && (
style={{
color: "#999",
fontSize: 12,
}}
numberOfLines={2}
>
{item.Overview || "No description available"}
</Text>
{item.RunTimeTicks && (
<Text <Text
style={{ style={{
color: "#666", color: "#999",
fontSize: 11, fontSize: 12,
marginTop: 4, marginBottom: 4,
}} }}
numberOfLines={2}
> >
{Math.round((item.RunTimeTicks / 600000000) * 10) / 10} min {item.Overview}
</Text> </Text>
)} )}
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
{item.ProductionYear && (
<Text style={{ color: "#666", fontSize: 11 }}>
{item.ProductionYear}
</Text>
)}
{item.RunTimeTicks && (
<Text style={{ color: "#666", fontSize: 11 }}>
{Math.round(item.RunTimeTicks / 600000000)} min
</Text>
)}
</View>
</View> </View>
{isCurrentEpisode && ( {isCurrentEpisode && (
@@ -130,49 +155,68 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
return ( return (
<Modal <Modal
visible={visible} visible={visible}
transparent={true}
animationType='slide' animationType='slide'
presentationStyle='formSheet'
onRequestClose={onClose} onRequestClose={onClose}
> >
<View <Pressable
style={{ style={{
flex: 1, flex: 1,
backgroundColor: "#000", backgroundColor: "rgba(0, 0, 0, 0.85)",
paddingTop: insets.top,
}} }}
onPress={onClose}
> >
{/* Header */} <Pressable
<View
style={{ style={{
flexDirection: "row", flex: 1,
justifyContent: "space-between", paddingTop: insets.top,
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#333",
}} }}
onPress={(e) => e.stopPropagation()}
> >
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}> {/* Header */}
Episodes <View
</Text> style={{
<Pressable onPress={onClose} style={{ padding: 8 }}> flexDirection: "row",
<Ionicons name='close' size={24} color='white' /> justifyContent: "space-between",
</Pressable> alignItems: "center",
</View> paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
Episodes
</Text>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
</Pressable>
</View>
{/* Episode list */} {/* Episode list */}
<FlatList <FlatList
data={episodes} ref={flatListRef}
renderItem={renderEpisode} data={episodes}
keyExtractor={(item) => item.Id || ""} renderItem={renderEpisode}
contentContainerStyle={{ keyExtractor={(item) => item.Id || ""}
padding: 16, contentContainerStyle={{
paddingBottom: insets.bottom + 16, padding: 16,
}} paddingBottom: insets.bottom + 16,
showsVerticalScrollIndicator={false} }}
/> showsVerticalScrollIndicator={false}
</View> onScrollToIndexFailed={(info) => {
// Fallback if scroll fails
setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: info.index,
animated: true,
viewPosition: 0.5,
});
}, 500);
}}
/>
</Pressable>
</Pressable>
</Modal> </Modal>
); );
}; };

View File

@@ -94,14 +94,14 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
return ( return (
<Modal <Modal
visible={visible} visible={visible}
transparent={true}
animationType='slide' animationType='slide'
presentationStyle='formSheet'
onRequestClose={onClose} onRequestClose={onClose}
> >
<Pressable <Pressable
style={{ style={{
flex: 1, flex: 1,
backgroundColor: "rgba(0,0,0,0.5)", backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "flex-end", justifyContent: "flex-end",
}} }}
onPress={onClose} onPress={onClose}

View File

@@ -120,13 +120,7 @@ const formatTranscodeReason = (reason: string): string => {
}; };
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo( export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
({ ({ visible, getTechnicalInfo, playMethod, transcodeReasons }) => {
showControls,
visible,
getTechnicalInfo,
playMethod,
transcodeReasons,
}) => {
const { settings } = useSettings(); const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null); const [info, setInfo] = useState<TechnicalInfo | null>(null);

View File

@@ -85,6 +85,16 @@ export const useCasting = (item: BaseItemDto | null) => {
} }
}, [mediaStatus, activeProtocol]); }, [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) // Progress reporting to Jellyfin (optimized to skip redundant reports)
useEffect(() => { useEffect(() => {
if (!isConnected || !item?.Id || !user?.Id || state.progress <= 0) return; if (!isConnected || !item?.Id || !user?.Id || state.progress <= 0) return;
@@ -209,8 +219,13 @@ export const useCasting = (item: BaseItemDto | null) => {
} }
volumeDebounceRef.current = setTimeout(async () => { volumeDebounceRef.current = setTimeout(async () => {
if (activeProtocol === "chromecast") { if (activeProtocol === "chromecast" && client && isConnected) {
await client?.setStreamVolume(clampedVolume).catch(console.error); await client.setStreamVolume(clampedVolume).catch((error) => {
console.log(
"[useCasting] Volume set failed (no session):",
error.message,
);
});
} }
// Future: Add volume control for other protocols // Future: Add volume control for other protocols
}, 300); }, 300);

View File

@@ -51,11 +51,6 @@
}, },
"casting_player": { "casting_player": {
"buffering": "Buffering...", "buffering": "Buffering...",
"episodes": "Episodes",
"favorite": "Favorite",
"next": "Next",
"previous": "Previous",
"end_playback": "End Playback",
"season_episode_format": "Season {{season}} • Episode {{episode}}", "season_episode_format": "Season {{season}} • Episode {{episode}}",
"connection_quality": { "connection_quality": {
"excellent": "Excellent", "excellent": "Excellent",