mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-21 06:16:43 +01:00
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.
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
/**
|
|
* Chromecast Device Info Sheet
|
|
* Shows device details, volume control, and disconnect option
|
|
*/
|
|
|
|
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 { 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";
|
|
|
|
interface ChromecastDeviceSheetProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
device: Device | null;
|
|
onDisconnect: () => Promise<void>;
|
|
volume?: number;
|
|
onVolumeChange?: (volume: number) => Promise<void>;
|
|
}
|
|
|
|
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|
visible,
|
|
onClose,
|
|
device,
|
|
onDisconnect,
|
|
volume = 0.5,
|
|
onVolumeChange,
|
|
}) => {
|
|
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 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 and mute updates when sheet is visible to catch physical button changes
|
|
useEffect(() => {
|
|
if (!visible || !castSession) return;
|
|
|
|
// 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, castSession, displayVolume, volumeValue, isMuted]);
|
|
|
|
const handleDisconnect = async () => {
|
|
setIsDisconnecting(true);
|
|
try {
|
|
await onDisconnect();
|
|
onClose();
|
|
} catch (error) {
|
|
console.error("Failed to disconnect:", error);
|
|
} finally {
|
|
setIsDisconnecting(false);
|
|
}
|
|
};
|
|
|
|
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) {
|
|
await 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);
|
|
}
|
|
};
|
|
|
|
// 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}
|
|
transparent={true}
|
|
animationType='slide'
|
|
onRequestClose={onClose}
|
|
>
|
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
<Pressable
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
|
justifyContent: "flex-end",
|
|
}}
|
|
onPress={onClose}
|
|
>
|
|
<Pressable
|
|
style={{
|
|
backgroundColor: "#1a1a1a",
|
|
borderTopLeftRadius: 16,
|
|
borderTopRightRadius: 16,
|
|
paddingBottom: insets.bottom + 16,
|
|
}}
|
|
onPress={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
padding: 16,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#333",
|
|
}}
|
|
>
|
|
<View
|
|
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
>
|
|
<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",
|
|
}}
|
|
>
|
|
<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>
|
|
</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>
|
|
</GestureHandlerRootView>
|
|
</Modal>
|
|
);
|
|
};
|