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
parent da52b9c4b3
commit 4ad07d22bd
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",
...props
}) {
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const _client = useRemoteMediaClient();
const _castDevice = useCastDevice();
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const _sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
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(() => {
(async () => {
let isSubscribed = true;
let retryTimeout: NodeJS.Timeout;
const startDiscoveryWithRetry = async () => {
if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return;
}
await discoveryManager.startDiscovery();
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
try {
// Stop any existing discovery first
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
useEffect(() => {
@@ -104,13 +153,11 @@ export function Chromecast({
<Pressable
className='mr-4'
onPress={() => {
console.log("Chromecast button tapped (iOS)", {
hasMediaStatus: !!mediaStatus,
currentItemId: mediaStatus?.currentItemId,
castDevice: castDevice?.friendlyName,
});
if (mediaStatus?.currentItemId) router.push("/casting-player");
else CastContext.showCastDialog();
if (mediaStatus?.currentItemId) {
router.push("/casting-player");
} else {
CastContext.showCastDialog();
}
}}
{...props}
>
@@ -127,14 +174,8 @@ export function Chromecast({
className='mr-2'
background={false}
onPress={() => {
console.log("Chromecast button tapped (Android transparent)", {
hasMediaStatus: !!mediaStatus,
currentItemId: mediaStatus?.currentItemId,
castDevice: castDevice?.friendlyName,
});
if (mediaStatus?.currentItemId) {
console.log("Navigating to: /(auth)/casting-player");
router.push("/(auth)/casting-player");
router.replace("/casting-player" as any);
} else {
CastContext.showCastDialog();
}
@@ -150,13 +191,11 @@ export function Chromecast({
<RoundButton
size='large'
onPress={() => {
console.log("Chromecast button tapped (Android)", {
hasMediaStatus: !!mediaStatus,
currentItemId: mediaStatus?.currentItemId,
castDevice: castDevice?.friendlyName,
});
if (mediaStatus?.currentItemId) router.push("/casting-player");
else CastContext.showCastDialog();
if (mediaStatus?.currentItemId) {
router.push("/casting-player");
} else {
CastContext.showCastDialog();
}
}}
{...props}
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,21 @@
"downloaded_file_no": "No",
"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": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com",