fix: Refactors Chromecast casting player

Refactors the Chromecast casting player for better compatibility, UI improvements, and stability.

- Adds auto-selection of stereo audio tracks for improved Chromecast compatibility
- Refactors episode list to filter out virtual episodes and allow season selection
- Improves UI layout and styling
- Removes connection quality indicator
- Fixes progress reporting to Jellyfin
- Updates volume control to use CastSession for device volume
This commit is contained in:
Uruk
2026-02-04 21:03:49 +01:00
committed by Gauvain
parent 99775b353f
commit 9dcbcdc41d
9 changed files with 569 additions and 367 deletions

View File

@@ -4,58 +4,111 @@
*/
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import React from "react";
import React, { useEffect, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import {
MediaPlayerState,
useCastDevice,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
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";
import {
formatTime,
getPosterUrl,
getProtocolIcon,
getProtocolName,
} from "@/utils/casting/helpers";
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
import { CASTING_CONSTANTS } from "@/utils/casting/types";
export const CastingMiniPlayer: React.FC = () => {
const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets();
const {
isConnected,
protocol,
currentItem,
currentDevice,
progress,
duration,
isPlaying,
togglePlayPause,
} = useCasting(null);
const castDevice = useCastDevice();
const mediaStatus = useMediaStatus();
const remoteMediaClient = useRemoteMediaClient();
if (!isConnected || !currentItem || !protocol) {
const currentItem = useMemo(() => {
return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined;
}, [mediaStatus?.mediaInfo?.customData]);
// Live progress state that updates every second when playing
const [liveProgress, setLiveProgress] = useState(
mediaStatus?.streamPosition || 0,
);
// Sync live progress with mediaStatus and poll every second when playing
useEffect(() => {
if (mediaStatus?.streamPosition) {
setLiveProgress(mediaStatus.streamPosition);
}
// Update every second when playing
const interval = setInterval(() => {
if (
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
mediaStatus?.streamPosition !== undefined
) {
setLiveProgress((prev) => prev + 1);
} else if (mediaStatus?.streamPosition !== undefined) {
// Sync with actual position when paused/buffering
setLiveProgress(mediaStatus.streamPosition);
}
}, 1000);
return () => clearInterval(interval);
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
const progress = liveProgress * 1000; // Convert to ms
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
// For episodes, use season poster; for other content, use item poster
const posterUrl = useMemo(() => {
if (!api?.basePath || !currentItem) return null;
if (
currentItem.Type === "Episode" &&
currentItem.SeriesId &&
currentItem.ParentIndexNumber
) {
// Build season poster URL using SeriesId and season number
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96&tag=${currentItem.SeasonId}`;
}
// For non-episodes, use item's own poster
return getPosterUrl(
api.basePath,
currentItem.Id,
currentItem.ImageTags?.Primary,
80,
120,
);
}, [api?.basePath, currentItem]);
if (!castDevice || !currentItem || !mediaStatus) {
return null;
}
const posterUrl = getPosterUrl(
api?.basePath,
currentItem.Id,
currentItem.ImageTags?.Primary,
80,
120,
);
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
const protocolColor = "#a855f7"; // Streamyfin purple
const TAB_BAR_HEIGHT = 49; // Standard tab bar height
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
const handlePress = () => {
router.push("/casting-player");
};
const handleTogglePlayPause = (e: any) => {
e.stopPropagation();
if (isPlaying) {
remoteMediaClient?.pause();
} else {
remoteMediaClient?.play();
}
};
return (
<Animated.View
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
@@ -141,11 +194,7 @@ export const CastingMiniPlayer: React.FC = () => {
marginTop: 2,
}}
>
<Ionicons
name={getProtocolIcon(protocol)}
size={12}
color={protocolColor}
/>
<Ionicons name='tv' size={12} color={protocolColor} />
<Text
style={{
color: protocolColor,
@@ -153,7 +202,7 @@ export const CastingMiniPlayer: React.FC = () => {
}}
numberOfLines={1}
>
{currentDevice?.name || getProtocolName(protocol)}
{castDevice.friendlyName || "Chromecast"}
</Text>
<Text
style={{
@@ -167,17 +216,7 @@ export const CastingMiniPlayer: React.FC = () => {
</View>
{/* Play/Pause button */}
<Pressable
onPress={(e) => {
e.stopPropagation();
if (isConnected && protocol) {
togglePlayPause();
}
}}
style={{
padding: 8,
}}
>
<Pressable onPress={handleTogglePlayPause} style={{ padding: 8 }}>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={28}

View File

@@ -8,6 +8,7 @@ import React, { useEffect, useState } from "react";
import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import type { Device } from "react-native-google-cast";
import { useCastSession, useRemoteMediaClient } from "react-native-google-cast";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -19,9 +20,6 @@ interface ChromecastDeviceSheetProps {
onDisconnect: () => Promise<void>;
volume?: number;
onVolumeChange?: (volume: number) => Promise<void>;
showTechnicalInfo?: boolean;
connectionQuality?: "excellent" | "good" | "fair" | "poor";
bitrate?: number;
}
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
@@ -31,19 +29,34 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
onDisconnect,
volume = 0.5,
onVolumeChange,
showTechnicalInfo = false,
connectionQuality = "good",
bitrate,
}) => {
const insets = useSafeAreaInsets();
const [isDisconnecting, setIsDisconnecting] = useState(false);
const volumeValue = useSharedValue(volume * 100);
const minimumValue = useSharedValue(0);
const maximumValue = useSharedValue(100);
const castSession = useCastSession();
const remoteMediaClient = useRemoteMediaClient();
// Sync volume slider with prop changes
// Sync volume slider with prop changes (updates from physical buttons)
useEffect(() => {
volumeValue.value = volume * 100;
}, [volume, volumeValue]);
// Poll for volume updates when sheet is visible to catch physical button changes
useEffect(() => {
if (!visible || !remoteMediaClient) return;
// Request status update to get latest volume from device
const interval = setInterval(() => {
remoteMediaClient.requestStatus().catch(() => {
// Ignore errors - device might be disconnected
});
}, 1000);
return () => clearInterval(interval);
}, [visible, remoteMediaClient]);
const handleDisconnect = async () => {
setIsDisconnecting(true);
try {
@@ -57,8 +70,19 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
};
const handleVolumeComplete = async (value: number) => {
if (onVolumeChange) {
await onVolumeChange(value / 100);
const newVolume = value / 100;
try {
// Use CastSession.setVolume for DEVICE volume control
// This works even when no media is playing, unlike setStreamVolume
if (castSession) {
castSession.setVolume(newVolume);
console.log("[Volume] Set device volume via CastSession:", newVolume);
} else if (onVolumeChange) {
// Fallback to prop method if session not available
await onVolumeChange(newVolume);
}
} catch (error) {
console.error("[Volume] Error setting volume:", error);
}
};
@@ -120,61 +144,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
{device?.friendlyName || device?.deviceId || "Unknown Device"}
</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 && (
{device?.deviceId && (
<View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
Device ID
@@ -183,11 +153,10 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
style={{ color: "white", fontSize: 14 }}
numberOfLines={1}
>
{device.deviceId}
{device?.deviceId}
</Text>
</View>
)}
{/* Volume control */}
<View style={{ marginBottom: 24 }}>
<View
@@ -211,8 +180,8 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<Slider
style={{ width: "100%", height: 40 }}
progress={volumeValue}
minimumValue={useSharedValue(0)}
maximumValue={useSharedValue(100)}
minimumValue={minimumValue}
maximumValue={maximumValue}
theme={{
disableMinTrackTintColor: "#333",
maximumTrackTintColor: "#333",
@@ -231,13 +200,11 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
}}
onSlidingComplete={handleVolumeComplete}
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
disable={false}
/>
</View>
<Ionicons name='volume-high' size={20} color='#999' />
</View>
</View>
{/* Disconnect button */}
<Pressable
onPress={handleDisconnect}
@@ -253,7 +220,12 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
opacity: isDisconnecting ? 0.5 : 1,
}}
>
<Ionicons name='power' size={20} color='white' />
<Ionicons
name='power'
size={20}
color='white'
style={{ marginTop: 2 }}
/>
<Text style={{ color: "white", fontSize: 16, fontWeight: "600" }}>
{isDisconnecting ? "Disconnecting..." : "Stop Casting"}
</Text>

View File

@@ -7,8 +7,8 @@ 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, { useEffect, useRef } from "react";
import { FlatList, Modal, Pressable, View } from "react-native";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { truncateTitle } from "@/utils/casting/helpers";
@@ -33,10 +33,47 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}) => {
const insets = useSafeAreaInsets();
const flatListRef = useRef<FlatList>(null);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
// Get unique seasons from episodes
const seasons = useMemo(() => {
const seasonSet = new Set<number>();
for (const ep of episodes) {
if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) {
seasonSet.add(ep.ParentIndexNumber);
}
}
return Array.from(seasonSet).sort((a, b) => a - b);
}, [episodes]);
// Filter episodes by selected season and exclude virtual episodes
const filteredEpisodes = useMemo(() => {
let eps = episodes;
// Filter by season if selected
if (selectedSeason !== null) {
eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason);
}
// Filter out virtual episodes (episodes without actual video files)
// LocationType === "Virtual" means the episode doesn't have a media file
eps = eps.filter((ep) => ep.LocationType !== "Virtual");
return eps;
}, [episodes, selectedSeason]);
// Set initial season to current episode's season
useEffect(() => {
if (currentItem?.ParentIndexNumber !== undefined) {
setSelectedSeason(currentItem.ParentIndexNumber);
}
}, [currentItem]);
useEffect(() => {
if (visible && currentItem && episodes.length > 0) {
const currentIndex = episodes.findIndex((ep) => ep.Id === currentItem.Id);
if (visible && currentItem && filteredEpisodes.length > 0) {
const currentIndex = filteredEpisodes.findIndex(
(ep) => ep.Id === currentItem.Id,
);
if (currentIndex !== -1 && flatListRef.current) {
// Delay to ensure FlatList is rendered
setTimeout(() => {
@@ -48,7 +85,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}, 300);
}
}
}, [visible, currentItem, episodes]);
}, [visible, currentItem, filteredEpisodes]);
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
const isCurrentEpisode = item.Id === currentItem?.Id;
@@ -125,6 +162,15 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
</Text>
)}
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
{item.ParentIndexNumber !== undefined &&
item.IndexNumber !== undefined && (
<Text
style={{ color: "#a855f7", fontSize: 11, fontWeight: "600" }}
>
S{String(item.ParentIndexNumber).padStart(2, "0")}:E
{String(item.IndexNumber).padStart(2, "0")}
</Text>
)}
{item.ProductionYear && (
<Text style={{ color: "#666", fontSize: 11 }}>
{item.ProductionYear}
@@ -176,27 +222,66 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
{/* 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
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: seasons.length > 1 ? 12 : 0,
}}
>
<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>
{/* Season selector */}
{seasons.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8 }}
>
{seasons.map((season) => (
<Pressable
key={season}
onPress={() => setSelectedSeason(season)}
style={{
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor:
selectedSeason === season ? "#a855f7" : "#1a1a1a",
}}
>
<Text
style={{
color: "white",
fontSize: 14,
fontWeight: selectedSeason === season ? "600" : "400",
}}
>
Season {season}
</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
{/* Episode list */}
<FlatList
ref={flatListRef}
data={episodes}
data={filteredEpisodes}
renderItem={renderEpisode}
keyExtractor={(item) => item.Id || ""}
contentContainerStyle={{

View File

@@ -30,8 +30,6 @@ interface ChromecastSettingsMenuProps {
onSubtitleTrackChange: (track: SubtitleTrack | null) => void;
playbackSpeed: number;
onPlaybackSpeedChange: (speed: number) => void;
showTechnicalInfo: boolean;
onToggleTechnicalInfo: () => void;
}
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
@@ -51,8 +49,6 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
onSubtitleTrackChange,
playbackSpeed,
onPlaybackSpeedChange,
showTechnicalInfo,
onToggleTechnicalInfo,
}) => {
const insets = useSafeAreaInsets();
const [expandedSection, setExpandedSection] = useState<string | null>(null);
@@ -316,50 +312,6 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
))}
</View>
)}
{/* Technical Info Toggle */}
<Pressable
onPress={onToggleTechnicalInfo}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
>
<Ionicons name='information-circle' size={20} color='white' />
<Text
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
>
Show Technical Info
</Text>
</View>
<View
style={{
width: 50,
height: 30,
borderRadius: 15,
backgroundColor: showTechnicalInfo ? "#a855f7" : "#333",
justifyContent: "center",
alignItems: showTechnicalInfo ? "flex-end" : "flex-start",
padding: 2,
}}
>
<View
style={{
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: "white",
}}
/>
</View>
</Pressable>
</ScrollView>
</Pressable>
</Pressable>