mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-03 08:58:07 +00:00
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:
@@ -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"
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user