mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-21 14:26:35 +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.
301 lines
9.7 KiB
TypeScript
301 lines
9.7 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
};
|