mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-24 20:18:09 +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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user