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 f32a242e77
commit 01ef066a3d
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
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) {
@@ -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() {
</Pressable>
</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 */}
<ScrollView
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: insets.top + 60,
paddingBottom: insets.bottom + 300,
paddingTop: insets.top + 140,
paddingBottom: insets.bottom + 500,
}}
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 */}
<View
style={{
alignItems: "center",
marginBottom: 32,
marginBottom: 40,
}}
>
<View
@@ -771,7 +843,6 @@ export default function CastingPlayerScreen() {
backgroundColor: "rgba(0,0,0,0.7)",
justifyContent: "center",
alignItems: "center",
backdropFilter: "blur(10px)",
}}
>
<ActivityIndicator size='large' color={protocolColor} />
@@ -789,16 +860,19 @@ export default function CastingPlayerScreen() {
</View>
</View>
{/* Spacer to push buttons down */}
<View style={{ height: 40 }} />
{/* 4-button control row for episodes */}
{currentItem.Type === "Episode" && episodes.length > 0 && (
{currentItem.Type === "Episode" && (
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 12,
marginBottom: 20,
paddingHorizontal: 16,
gap: 16,
marginBottom: 40,
paddingHorizontal: 20,
}}
>
{/* Episodes button */}
@@ -812,66 +886,9 @@ export default function CastingPlayerScreen() {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 6,
}}
>
<Ionicons name='list' size={18} 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>
<Ionicons name='list' size={22} color='white' />
</Pressable>
{/* 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,
}}
>
<Ionicons name='play-skip-back' size={18} color='white' />
<Text
style={{ color: "white", fontSize: 13, fontWeight: "600" }}
>
{t("casting_player.previous")}
</Text>
<Ionicons name='play-skip-back' size={22} color='white' />
</Pressable>
{/* Next episode button */}
@@ -927,20 +938,57 @@ export default function CastingPlayerScreen() {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 6,
opacity: nextEpisode ? 1 : 0.4,
}}
>
<Ionicons name='play-skip-forward' size={18} color='white' />
<Text
style={{ color: "white", fontSize: 13, fontWeight: "600" }}
>
{t("casting_player.next")}
</Text>
<Ionicons name='play-skip-forward' size={22} color='white' />
</Pressable>
{/* Stop casting button */}
<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={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='stop-circle' size={22} color='white' />
</Pressable>
</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 */}
<View style={{ marginBottom: 16, marginTop: 8 }}>
<GestureDetector gesture={progressPanGesture}>
@@ -1003,47 +1051,6 @@ export default function CastingPlayerScreen() {
</Text>
</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 */}
<View
style={{
@@ -1129,56 +1136,6 @@ export default function CastingPlayerScreen() {
)}
</Pressable>
</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>
{/* 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

View File

@@ -176,6 +176,15 @@ export const PlayButton: React.FC<Props> = ({
});
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<Props> = ({
? 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<Props> = ({
contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED,
streamDuration: streamDurationSeconds,
customData: item,
metadata:
item.Type === "Episode"
? {

View File

@@ -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,
}}
>
<Pressable onPress={handlePress}>
@@ -166,7 +170,9 @@ export const CastingMiniPlayer: React.FC = () => {
<Pressable
onPress={(e) => {
e.stopPropagation();
togglePlayPause();
if (isConnected && protocol) {
togglePlayPause();
}
}}
style={{
padding: 8,

View File

@@ -20,6 +20,8 @@ interface ChromecastDeviceSheetProps {
volume?: number;
onVolumeChange?: (volume: number) => Promise<void>;
showTechnicalInfo?: boolean;
connectionQuality?: "excellent" | "good" | "fair" | "poor";
bitrate?: number;
}
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
@@ -30,6 +32,8 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
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<ChromecastDeviceSheetProps> = ({
return (
<Modal
visible={visible}
transparent={true}
animationType='slide'
presentationStyle='formSheet'
onRequestClose={onClose}
>
<Pressable
style={{
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "flex-end",
}}
onPress={onClose}
@@ -117,6 +121,59 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
</Text>
</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 && (
<View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
@@ -162,10 +219,19 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
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}
/>
</View>
<Ionicons name='volume-high' size={20} color='#999' />

View File

@@ -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<ChromecastEpisodeListProps> = ({
@@ -26,8 +29,26 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
currentItem,
episodes,
onSelectEpisode,
api,
}) => {
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 isCurrentEpisode = item.Id === currentItem?.Id;
@@ -56,16 +77,16 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
backgroundColor: "#1a1a1a",
}}
>
{item.ImageTags?.Primary && (
{api && item.Id && (
<Image
source={{
uri: `${item.Id}/Images/Primary`,
uri: getPrimaryImageUrl({ api, item }) || undefined,
}}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
{!item.ImageTags?.Primary && (
{(!api || !item.Id) && (
<View
style={{
flex: 1,
@@ -91,26 +112,30 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
>
{item.IndexNumber}. {truncateTitle(item.Name || "Unknown", 30)}
</Text>
<Text
style={{
color: "#999",
fontSize: 12,
}}
numberOfLines={2}
>
{item.Overview || "No description available"}
</Text>
{item.RunTimeTicks && (
{item.Overview && (
<Text
style={{
color: "#666",
fontSize: 11,
marginTop: 4,
color: "#999",
fontSize: 12,
marginBottom: 4,
}}
numberOfLines={2}
>
{Math.round((item.RunTimeTicks / 600000000) * 10) / 10} min
{item.Overview}
</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>
{isCurrentEpisode && (
@@ -130,49 +155,68 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
return (
<Modal
visible={visible}
transparent={true}
animationType='slide'
presentationStyle='formSheet'
onRequestClose={onClose}
>
<View
<Pressable
style={{
flex: 1,
backgroundColor: "#000",
paddingTop: insets.top,
backgroundColor: "rgba(0, 0, 0, 0.85)",
}}
onPress={onClose}
>
{/* Header */}
<View
<Pressable
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#333",
flex: 1,
paddingTop: insets.top,
}}
onPress={(e) => e.stopPropagation()}
>
<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>
{/* Header */}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
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 */}
<FlatList
data={episodes}
renderItem={renderEpisode}
keyExtractor={(item) => item.Id || ""}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
showsVerticalScrollIndicator={false}
/>
</View>
{/* Episode list */}
<FlatList
ref={flatListRef}
data={episodes}
renderItem={renderEpisode}
keyExtractor={(item) => 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);
}}
/>
</Pressable>
</Pressable>
</Modal>
);
};

View File

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

View File

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

View File

@@ -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);

View File

@@ -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",