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

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