mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-21 22:36:36 +01:00
feat: Enhances casting player with trickplay
Implements trickplay functionality with preview images to improve the casting player's seeking experience. Adds a progress slider with trickplay preview, allowing users to scrub through media with visual feedback. Integrates device volume control and mute functionality to the Chromecast device sheet. Also fixes minor bugs and improves UI.
This commit is contained in:
300
components/chromecast/ChromecastConnectionMenu.tsx
Normal file
300
components/chromecast/ChromecastConnectionMenu.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Chromecast Connection Menu
|
||||
* Shows device info, volume control, and disconnect option
|
||||
* Simple menu for when connected but not actively controlling playback
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Modal, Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { useCastDevice, useCastSession } 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";
|
||||
|
||||
interface ChromecastConnectionMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDisconnect?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ChromecastConnectionMenu: React.FC<
|
||||
ChromecastConnectionMenuProps
|
||||
> = ({ visible, onClose, onDisconnect }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const castDevice = useCastDevice();
|
||||
const castSession = useCastSession();
|
||||
|
||||
// Volume state - use refs to avoid triggering re-renders during sliding
|
||||
const [displayVolume, setDisplayVolume] = useState(50);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const volumeValue = useSharedValue(50);
|
||||
const minimumValue = useSharedValue(0);
|
||||
const maximumValue = useSharedValue(100);
|
||||
const isSliding = useRef(false);
|
||||
const lastSetVolume = useRef(50);
|
||||
|
||||
const protocolColor = "#a855f7";
|
||||
|
||||
// Get initial volume and mute state when menu opens
|
||||
useEffect(() => {
|
||||
if (!visible || !castSession) return;
|
||||
|
||||
// Get initial states
|
||||
const fetchInitialState = async () => {
|
||||
try {
|
||||
const vol = await castSession.getVolume();
|
||||
if (vol !== undefined) {
|
||||
const percent = Math.round(vol * 100);
|
||||
setDisplayVolume(percent);
|
||||
volumeValue.value = percent;
|
||||
lastSetVolume.current = percent;
|
||||
}
|
||||
const muted = await castSession.isMute();
|
||||
setIsMuted(muted);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
};
|
||||
fetchInitialState();
|
||||
|
||||
// Poll for external volume changes (physical buttons) - only when not sliding
|
||||
const interval = setInterval(async () => {
|
||||
if (isSliding.current) return;
|
||||
|
||||
try {
|
||||
const vol = await castSession.getVolume();
|
||||
if (vol !== undefined) {
|
||||
const percent = Math.round(vol * 100);
|
||||
// Only update if external change detected (not our own change)
|
||||
if (Math.abs(percent - lastSetVolume.current) > 2) {
|
||||
setDisplayVolume(percent);
|
||||
volumeValue.value = percent;
|
||||
lastSetVolume.current = percent;
|
||||
}
|
||||
}
|
||||
const muted = await castSession.isMute();
|
||||
if (muted !== isMuted) {
|
||||
setIsMuted(muted);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, 1000); // Poll less frequently
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, castSession, volumeValue, isMuted]);
|
||||
|
||||
// Volume change during sliding - update display only, don't call API
|
||||
const handleVolumeChange = useCallback((value: number) => {
|
||||
const rounded = Math.round(value);
|
||||
setDisplayVolume(rounded);
|
||||
}, []);
|
||||
|
||||
// Volume change complete - call API
|
||||
const handleVolumeComplete = useCallback(
|
||||
async (value: number) => {
|
||||
isSliding.current = false;
|
||||
const rounded = Math.round(value);
|
||||
setDisplayVolume(rounded);
|
||||
lastSetVolume.current = rounded;
|
||||
|
||||
try {
|
||||
if (castSession) {
|
||||
await castSession.setVolume(value / 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Volume error:", error);
|
||||
}
|
||||
},
|
||||
[castSession],
|
||||
);
|
||||
|
||||
// Toggle mute
|
||||
const handleToggleMute = useCallback(async () => {
|
||||
if (!castSession) return;
|
||||
try {
|
||||
const newMute = !isMuted;
|
||||
await castSession.setMute(newMute);
|
||||
setIsMuted(newMute);
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Mute error:", error);
|
||||
}
|
||||
}, [castSession, isMuted]);
|
||||
|
||||
// Disconnect
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
try {
|
||||
if (onDisconnect) {
|
||||
await onDisconnect();
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("[Connection Menu] Disconnect error:", error);
|
||||
}
|
||||
}, [onDisconnect, onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with device name */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: protocolColor,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='tv' size={20} color='white' />
|
||||
</View>
|
||||
<View>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
||||
>
|
||||
{castDevice?.friendlyName || "Chromecast"}
|
||||
</Text>
|
||||
<Text style={{ color: protocolColor, fontSize: 12 }}>
|
||||
Connected
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Volume Control */}
|
||||
<View style={{ padding: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>Volume</Text>
|
||||
<Text style={{ color: "white", fontSize: 14 }}>
|
||||
{isMuted ? "Muted" : `${displayVolume}%`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={handleToggleMute}
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: isMuted ? protocolColor : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isMuted ? "volume-mute" : "volume-low"}
|
||||
size={20}
|
||||
color={isMuted ? "white" : "#999"}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={volumeValue}
|
||||
minimumValue={minimumValue}
|
||||
maximumValue={maximumValue}
|
||||
theme={{
|
||||
disableMinTrackTintColor: "#333",
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: isMuted ? "#666" : protocolColor,
|
||||
bubbleBackgroundColor: protocolColor,
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
isSliding.current = true;
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
volumeValue.value = value;
|
||||
handleVolumeChange(value);
|
||||
if (isMuted) {
|
||||
setIsMuted(false);
|
||||
castSession?.setMute(false);
|
||||
}
|
||||
}}
|
||||
onSlidingComplete={handleVolumeComplete}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||
/>
|
||||
</View>
|
||||
<Ionicons
|
||||
name='volume-high'
|
||||
size={20}
|
||||
color={isMuted ? "#666" : "#999"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Disconnect button */}
|
||||
<View style={{ paddingHorizontal: 16 }}>
|
||||
<Pressable
|
||||
onPress={handleDisconnect}
|
||||
style={{
|
||||
backgroundColor: protocolColor,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='power' size={20} color='white' />
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
|
||||
>
|
||||
Disconnect
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -4,11 +4,11 @@
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, 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 { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { type Device, useCastSession } 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";
|
||||
@@ -32,30 +32,60 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
|
||||
const volumeValue = useSharedValue(volume * 100);
|
||||
const minimumValue = useSharedValue(0);
|
||||
const maximumValue = useSharedValue(100);
|
||||
const castSession = useCastSession();
|
||||
const remoteMediaClient = useRemoteMediaClient();
|
||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const isSliding = useRef(false);
|
||||
const lastSetVolume = useRef(Math.round(volume * 100));
|
||||
|
||||
// Sync volume slider with prop changes (updates from physical buttons)
|
||||
useEffect(() => {
|
||||
volumeValue.value = volume * 100;
|
||||
setDisplayVolume(Math.round(volume * 100));
|
||||
}, [volume, volumeValue]);
|
||||
|
||||
// Poll for volume updates when sheet is visible to catch physical button changes
|
||||
// Poll for volume and mute updates when sheet is visible to catch physical button changes
|
||||
useEffect(() => {
|
||||
if (!visible || !remoteMediaClient) return;
|
||||
if (!visible || !castSession) return;
|
||||
|
||||
// Request status update to get latest volume from device
|
||||
const interval = setInterval(() => {
|
||||
remoteMediaClient.requestStatus().catch(() => {
|
||||
// Get initial mute state
|
||||
castSession
|
||||
.isMute()
|
||||
.then(setIsMuted)
|
||||
.catch(() => {});
|
||||
|
||||
// Poll CastSession for device volume and mute state (only when not sliding)
|
||||
const interval = setInterval(async () => {
|
||||
if (isSliding.current) return;
|
||||
|
||||
try {
|
||||
const deviceVolume = await castSession.getVolume();
|
||||
if (deviceVolume !== undefined) {
|
||||
const volumePercent = Math.round(deviceVolume * 100);
|
||||
// Only update if external change (physical buttons)
|
||||
if (Math.abs(volumePercent - lastSetVolume.current) > 2) {
|
||||
setDisplayVolume(volumePercent);
|
||||
volumeValue.value = volumePercent;
|
||||
lastSetVolume.current = volumePercent;
|
||||
}
|
||||
}
|
||||
|
||||
// Check mute state
|
||||
const muteState = await castSession.isMute();
|
||||
if (muteState !== isMuted) {
|
||||
setIsMuted(muteState);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors - device might be disconnected
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, remoteMediaClient]);
|
||||
}, [visible, castSession, displayVolume, volumeValue, isMuted]);
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setIsDisconnecting(true);
|
||||
@@ -71,11 +101,12 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
||||
|
||||
const handleVolumeComplete = async (value: number) => {
|
||||
const newVolume = value / 100;
|
||||
setDisplayVolume(Math.round(value));
|
||||
try {
|
||||
// Use CastSession.setVolume for DEVICE volume control
|
||||
// This works even when no media is playing, unlike setStreamVolume
|
||||
if (castSession) {
|
||||
castSession.setVolume(newVolume);
|
||||
await castSession.setVolume(newVolume);
|
||||
console.log("[Volume] Set device volume via CastSession:", newVolume);
|
||||
} else if (onVolumeChange) {
|
||||
// Fallback to prop method if session not available
|
||||
@@ -86,6 +117,42 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced volume update during sliding for smooth live feedback
|
||||
const handleVolumeChange = useCallback(
|
||||
(value: number) => {
|
||||
setDisplayVolume(Math.round(value));
|
||||
|
||||
// Debounce the API call to avoid too many requests
|
||||
if (volumeDebounceRef.current) {
|
||||
clearTimeout(volumeDebounceRef.current);
|
||||
}
|
||||
|
||||
volumeDebounceRef.current = setTimeout(async () => {
|
||||
const newVolume = value / 100;
|
||||
try {
|
||||
if (castSession) {
|
||||
await castSession.setVolume(newVolume);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during sliding
|
||||
}
|
||||
}, 150); // 150ms debounce
|
||||
},
|
||||
[castSession],
|
||||
);
|
||||
|
||||
// Toggle mute state
|
||||
const handleToggleMute = useCallback(async () => {
|
||||
if (!castSession) return;
|
||||
try {
|
||||
const newMuteState = !isMuted;
|
||||
await castSession.setMute(newMuteState);
|
||||
setIsMuted(newMuteState);
|
||||
} catch (error) {
|
||||
console.error("[Volume] Error toggling mute:", error);
|
||||
}
|
||||
}, [castSession, isMuted]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
@@ -93,146 +160,170 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
onPress={onClose}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
<Pressable
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='tv' size={24} color='#a855f7' />
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||
Chromecast
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Device info */}
|
||||
<View style={{ padding: 16 }}>
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
||||
Device Name
|
||||
</Text>
|
||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
||||
{device?.friendlyName || device?.deviceId || "Unknown Device"}
|
||||
</Text>
|
||||
</View>
|
||||
{device?.deviceId && (
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
||||
Device ID
|
||||
</Text>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 14 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{device?.deviceId}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Volume control */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>Volume</Text>
|
||||
<Text style={{ color: "white", fontSize: 14 }}>
|
||||
{Math.round((volume || 0) * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Ionicons name='volume-low' size={20} color='#999' />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={volumeValue}
|
||||
minimumValue={minimumValue}
|
||||
maximumValue={maximumValue}
|
||||
theme={{
|
||||
disableMinTrackTintColor: "#333",
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: "#a855f7",
|
||||
bubbleBackgroundColor: "#a855f7",
|
||||
<Ionicons name='tv' size={24} color='#a855f7' />
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 18, fontWeight: "600" }}
|
||||
>
|
||||
Chromecast
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Device info */}
|
||||
<View style={{ padding: 16 }}>
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
||||
Device Name
|
||||
</Text>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
|
||||
>
|
||||
{device?.friendlyName || "Unknown Device"}
|
||||
</Text>
|
||||
</View>
|
||||
{/* Volume control */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>Volume</Text>
|
||||
<Text style={{ color: "white", fontSize: 14 }}>
|
||||
{isMuted ? "Muted" : `${displayVolume}%`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Mute button */}
|
||||
<Pressable
|
||||
onPress={handleToggleMute}
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: isMuted ? "#a855f7" : "transparent",
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
console.log(
|
||||
"[Volume] Sliding started",
|
||||
volumeValue.value,
|
||||
);
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
volumeValue.value = value;
|
||||
console.log("[Volume] Value changed", value);
|
||||
}}
|
||||
onSlidingComplete={handleVolumeComplete}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={isMuted ? "volume-mute" : "volume-low"}
|
||||
size={20}
|
||||
color={isMuted ? "white" : "#999"}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={volumeValue}
|
||||
minimumValue={minimumValue}
|
||||
maximumValue={maximumValue}
|
||||
theme={{
|
||||
disableMinTrackTintColor: "#333",
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: isMuted ? "#666" : "#a855f7",
|
||||
bubbleBackgroundColor: "#a855f7",
|
||||
}}
|
||||
onSlidingStart={() => {
|
||||
isSliding.current = true;
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
volumeValue.value = value;
|
||||
handleVolumeChange(value);
|
||||
// Unmute when adjusting volume
|
||||
if (isMuted) {
|
||||
setIsMuted(false);
|
||||
castSession?.setMute(false);
|
||||
}
|
||||
}}
|
||||
onSlidingComplete={(value) => {
|
||||
isSliding.current = false;
|
||||
lastSetVolume.current = Math.round(value);
|
||||
handleVolumeComplete(value);
|
||||
}}
|
||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
||||
/>
|
||||
</View>
|
||||
<Ionicons
|
||||
name='volume-high'
|
||||
size={20}
|
||||
color={isMuted ? "#666" : "#999"}
|
||||
/>
|
||||
</View>
|
||||
<Ionicons name='volume-high' size={20} color='#999' />
|
||||
</View>
|
||||
|
||||
{/* Disconnect button */}
|
||||
<Pressable
|
||||
onPress={handleDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
style={{
|
||||
backgroundColor: "#a855f7",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
opacity: isDisconnecting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='power'
|
||||
size={20}
|
||||
color='white'
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
||||
>
|
||||
{isDisconnecting ? "Disconnecting..." : "Stop Casting"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{/* Disconnect button */}
|
||||
<Pressable
|
||||
onPress={handleDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
style={{
|
||||
backgroundColor: "#a855f7",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
opacity: isDisconnecting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='power'
|
||||
size={20}
|
||||
color='white'
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "600" }}>
|
||||
{isDisconnecting ? "Disconnecting..." : "Stop Casting"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user