mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-29 18:18:26 +01:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user