feat: Enhance Chromecast functionality and UI improvements

- Implemented a retry mechanism for Chromecast device discovery with a maximum of 3 attempts.
- Added logging for discovered devices to aid in debugging.
- Updated Chromecast button interactions to streamline navigation to the casting player.
- Changed the color scheme for Chromecast components to a consistent purple theme.
- Modified the ChromecastDeviceSheet to sync volume slider with prop changes.
- Improved the ChromecastSettingsMenu to conditionally render audio and subtitle tracks based on availability.
- Updated translations for the casting player to include new strings for better user experience.
This commit is contained in:
Uruk
2026-01-22 18:57:56 +01:00
committed by Gauvain
parent d4f730fc54
commit 7589ccd284
9 changed files with 1135 additions and 475 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -23,27 +23,76 @@ export function Chromecast({
background = "transparent", background = "transparent",
...props ...props
}) { }) {
const client = useRemoteMediaClient(); const _client = useRemoteMediaClient();
const castDevice = useCastDevice(); const _castDevice = useCastDevice();
const devices = useDevices(); const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager(); const _sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager(); const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const lastReportedProgressRef = useRef(0); const lastReportedProgressRef = useRef(0);
const discoveryAttempts = useRef(0);
const maxDiscoveryAttempts = 3;
const hasLoggedDevices = useRef(false);
// Enhanced discovery with retry mechanism - runs once on mount
useEffect(() => { useEffect(() => {
(async () => { let isSubscribed = true;
let retryTimeout: NodeJS.Timeout;
const startDiscoveryWithRetry = async () => {
if (!discoveryManager) { if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return; return;
} }
await discoveryManager.startDiscovery(); try {
})(); // Stop any existing discovery first
}, [client, devices, castDevice, sessionManager, discoveryManager]); try {
await discoveryManager.stopDiscovery();
} catch (_e) {
// Ignore errors when stopping
}
// Start fresh discovery
await discoveryManager.startDiscovery();
discoveryAttempts.current = 0; // Reset on success
} catch (error) {
console.error("[Chromecast Discovery] Failed:", error);
// Retry on error
if (discoveryAttempts.current < maxDiscoveryAttempts && isSubscribed) {
discoveryAttempts.current++;
retryTimeout = setTimeout(() => {
if (isSubscribed) {
startDiscoveryWithRetry();
}
}, 2000);
}
}
};
startDiscoveryWithRetry();
return () => {
isSubscribed = false;
if (retryTimeout) {
clearTimeout(retryTimeout);
}
};
}, [discoveryManager]); // Only re-run if discoveryManager changes
// Log device changes for debugging - only once per session
useEffect(() => {
if (devices.length > 0 && !hasLoggedDevices.current) {
console.log(
"[Chromecast] Found device(s):",
devices.map((d) => d.friendlyName || d.deviceId).join(", "),
);
hasLoggedDevices.current = true;
}
}, [devices]);
// Report video progress to Jellyfin server // Report video progress to Jellyfin server
useEffect(() => { useEffect(() => {
@@ -104,13 +153,11 @@ export function Chromecast({
<Pressable <Pressable
className='mr-4' className='mr-4'
onPress={() => { onPress={() => {
console.log("Chromecast button tapped (iOS)", { if (mediaStatus?.currentItemId) {
hasMediaStatus: !!mediaStatus, router.push("/casting-player");
currentItemId: mediaStatus?.currentItemId, } else {
castDevice: castDevice?.friendlyName, CastContext.showCastDialog();
}); }
if (mediaStatus?.currentItemId) router.push("/casting-player");
else CastContext.showCastDialog();
}} }}
{...props} {...props}
> >
@@ -127,14 +174,8 @@ export function Chromecast({
className='mr-2' className='mr-2'
background={false} background={false}
onPress={() => { onPress={() => {
console.log("Chromecast button tapped (Android transparent)", {
hasMediaStatus: !!mediaStatus,
currentItemId: mediaStatus?.currentItemId,
castDevice: castDevice?.friendlyName,
});
if (mediaStatus?.currentItemId) { if (mediaStatus?.currentItemId) {
console.log("Navigating to: /(auth)/casting-player"); router.replace("/casting-player" as any);
router.push("/(auth)/casting-player");
} else { } else {
CastContext.showCastDialog(); CastContext.showCastDialog();
} }
@@ -150,13 +191,11 @@ export function Chromecast({
<RoundButton <RoundButton
size='large' size='large'
onPress={() => { onPress={() => {
console.log("Chromecast button tapped (Android)", { if (mediaStatus?.currentItemId) {
hasMediaStatus: !!mediaStatus, router.push("/casting-player");
currentItemId: mediaStatus?.currentItemId, } else {
castDevice: castDevice?.friendlyName, CastContext.showCastDialog();
}); }
if (mediaStatus?.currentItemId) router.push("/casting-player");
else CastContext.showCastDialog();
}} }}
{...props} {...props}
> >

View File

@@ -261,7 +261,7 @@ export const PlayButton: React.FC<Props> = ({
if (isOpeningCurrentlyPlayingMedia) { if (isOpeningCurrentlyPlayingMedia) {
return; return;
} }
CastContext.showExpandedControls(); router.push("/casting-player");
}); });
} catch (e) { } catch (e) {
console.log(e); console.log(e);

View File

@@ -47,7 +47,7 @@ export const CastingMiniPlayer: React.FC = () => {
); );
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0; const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
const protocolColor = protocol === "chromecast" ? "#F9AB00" : "#666"; // Google yellow const protocolColor = "#a855f7"; // Streamyfin purple
const handlePress = () => { const handlePress = () => {
router.push("/casting-player"); router.push("/casting-player");

View File

@@ -4,7 +4,7 @@
*/ */
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { Modal, Pressable, View } from "react-native"; import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import type { Device } from "react-native-google-cast"; import type { Device } from "react-native-google-cast";
@@ -19,6 +19,7 @@ interface ChromecastDeviceSheetProps {
onDisconnect: () => Promise<void>; onDisconnect: () => Promise<void>;
volume?: number; volume?: number;
onVolumeChange?: (volume: number) => Promise<void>; onVolumeChange?: (volume: number) => Promise<void>;
showTechnicalInfo?: boolean;
} }
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
@@ -28,11 +29,17 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
onDisconnect, onDisconnect,
volume = 0.5, volume = 0.5,
onVolumeChange, onVolumeChange,
showTechnicalInfo = false,
}) => { }) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [isDisconnecting, setIsDisconnecting] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false);
const volumeValue = useSharedValue(volume * 100); const volumeValue = useSharedValue(volume * 100);
// Sync volume slider with prop changes
useEffect(() => {
volumeValue.value = volume * 100;
}, [volume, volumeValue]);
const handleDisconnect = async () => { const handleDisconnect = async () => {
setIsDisconnecting(true); setIsDisconnecting(true);
try { try {
@@ -55,9 +62,8 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<Modal <Modal
visible={visible} visible={visible}
animationType='slide' animationType='slide'
presentationStyle='pageSheet' presentationStyle='formSheet'
onRequestClose={onClose} onRequestClose={onClose}
transparent
> >
<Pressable <Pressable
style={{ style={{
@@ -90,7 +96,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<View <View
style={{ flexDirection: "row", alignItems: "center", gap: 12 }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
> >
<Ionicons name='tv' size={24} color='#e50914' /> <Ionicons name='tv' size={24} color='#a855f7' />
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}> <Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
Chromecast Chromecast
</Text> </Text>
@@ -111,7 +117,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
</Text> </Text>
</View> </View>
{device?.deviceId && ( {device?.deviceId && showTechnicalInfo && (
<View style={{ marginBottom: 20 }}> <View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}> <Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
Device ID Device ID
@@ -153,8 +159,8 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
theme={{ theme={{
disableMinTrackTintColor: "#333", disableMinTrackTintColor: "#333",
maximumTrackTintColor: "#333", maximumTrackTintColor: "#333",
minimumTrackTintColor: "#e50914", minimumTrackTintColor: "#a855f7",
bubbleBackgroundColor: "#e50914", bubbleBackgroundColor: "#a855f7",
}} }}
onValueChange={(value) => { onValueChange={(value) => {
volumeValue.value = value; volumeValue.value = value;
@@ -171,7 +177,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
onPress={handleDisconnect} onPress={handleDisconnect}
disabled={isDisconnecting} disabled={isDisconnecting}
style={{ style={{
backgroundColor: "#e50914", backgroundColor: "#a855f7",
padding: 16, padding: 16,
borderRadius: 8, borderRadius: 8,
flexDirection: "row", flexDirection: "row",

View File

@@ -41,7 +41,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
style={{ style={{
flexDirection: "row", flexDirection: "row",
padding: 12, padding: 12,
backgroundColor: isCurrentEpisode ? "#e50914" : "transparent", backgroundColor: isCurrentEpisode ? "#a855f7" : "transparent",
borderRadius: 8, borderRadius: 8,
marginBottom: 8, marginBottom: 8,
}} }}
@@ -131,7 +131,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
<Modal <Modal
visible={visible} visible={visible}
animationType='slide' animationType='slide'
presentationStyle='pageSheet' presentationStyle='formSheet'
onRequestClose={onClose} onRequestClose={onClose}
> >
<View <View

View File

@@ -95,9 +95,8 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
<Modal <Modal
visible={visible} visible={visible}
animationType='slide' animationType='slide'
presentationStyle='pageSheet' presentationStyle='formSheet'
onRequestClose={onClose} onRequestClose={onClose}
transparent
> >
<Pressable <Pressable
style={{ style={{
@@ -172,16 +171,17 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
)} )}
</View> </View>
{selectedMediaSource?.id === source.id && ( {selectedMediaSource?.id === source.id && (
<Ionicons name='checkmark' size={20} color='#e50914' /> <Ionicons name='checkmark' size={20} color='#a855f7' />
)} )}
</Pressable> </Pressable>
))} ))}
</View> </View>
)} )}
{/* Audio Tracks */} {/* Audio Tracks - only show if more than one track */}
{renderSectionHeader("Audio", "musical-notes", "audio")} {audioTracks.length > 1 &&
{expandedSection === "audio" && ( renderSectionHeader("Audio", "musical-notes", "audio")}
{audioTracks.length > 1 && expandedSection === "audio" && (
<View style={{ paddingVertical: 8 }}> <View style={{ paddingVertical: 8 }}>
{audioTracks.map((track) => ( {audioTracks.map((track) => (
<Pressable <Pressable
@@ -214,16 +214,17 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
)} )}
</View> </View>
{selectedAudioTrack?.index === track.index && ( {selectedAudioTrack?.index === track.index && (
<Ionicons name='checkmark' size={20} color='#e50914' /> <Ionicons name='checkmark' size={20} color='#a855f7' />
)} )}
</Pressable> </Pressable>
))} ))}
</View> </View>
)} )}
{/* Subtitle Tracks */} {/* Subtitle Tracks - only show if subtitles available */}
{renderSectionHeader("Subtitles", "text", "subtitles")} {subtitleTracks.length > 0 &&
{expandedSection === "subtitles" && ( renderSectionHeader("Subtitles", "text", "subtitles")}
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
<View style={{ paddingVertical: 8 }}> <View style={{ paddingVertical: 8 }}>
<Pressable <Pressable
onPress={() => { onPress={() => {
@@ -243,7 +244,7 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
> >
<Text style={{ color: "white", fontSize: 15 }}>None</Text> <Text style={{ color: "white", fontSize: 15 }}>None</Text>
{selectedSubtitleTrack === null && ( {selectedSubtitleTrack === null && (
<Ionicons name='checkmark' size={20} color='#e50914' /> <Ionicons name='checkmark' size={20} color='#a855f7' />
)} )}
</Pressable> </Pressable>
{subtitleTracks.map((track) => ( {subtitleTracks.map((track) => (
@@ -278,7 +279,7 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
)} )}
</View> </View>
{selectedSubtitleTrack?.index === track.index && ( {selectedSubtitleTrack?.index === track.index && (
<Ionicons name='checkmark' size={20} color='#e50914' /> <Ionicons name='checkmark' size={20} color='#a855f7' />
)} )}
</Pressable> </Pressable>
))} ))}
@@ -309,7 +310,7 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
{speed === 1 ? "Normal" : `${speed}x`} {speed === 1 ? "Normal" : `${speed}x`}
</Text> </Text>
{playbackSpeed === speed && ( {playbackSpeed === speed && (
<Ionicons name='checkmark' size={20} color='#e50914' /> <Ionicons name='checkmark' size={20} color='#a855f7' />
)} )}
</Pressable> </Pressable>
))} ))}
@@ -343,7 +344,7 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
width: 50, width: 50,
height: 30, height: 30,
borderRadius: 15, borderRadius: 15,
backgroundColor: showTechnicalInfo ? "#e50914" : "#333", backgroundColor: showTechnicalInfo ? "#a855f7" : "#333",
justifyContent: "center", justifyContent: "center",
alignItems: showTechnicalInfo ? "flex-end" : "flex-start", alignItems: showTechnicalInfo ? "flex-end" : "flex-start",
padding: 2, padding: 2,

View File

@@ -47,7 +47,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return ( return (
<MoreMoviesWithActor <MoreMoviesWithActor
key={person.Id} key={`${person.Id}-${idx}`}
currentItem={item} currentItem={item}
actorId={person.Id} actorId={person.Id}
actorName={person.Name} actorName={person.Name}

View File

@@ -49,6 +49,21 @@
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel"
}, },
"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",
"good": "Good",
"fair": "Fair",
"poor": "Poor"
}
},
"server": { "server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server", "enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com", "server_url_placeholder": "http(s)://your-server.com",