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:
Uruk
2026-02-05 22:28:18 +01:00
committed by Gauvain
parent 9dcbcdc41d
commit 68d64fec9c
6 changed files with 1241 additions and 322 deletions

View File

@@ -3,18 +3,21 @@ import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/mo
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import GoogleCast, {
CastButton,
CastContext,
CastState,
useCastDevice,
useCastState,
useDevices,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu";
import { RoundButton } from "./RoundButton";
export function Chromecast({
@@ -25,6 +28,7 @@ export function Chromecast({
}) {
const _client = useRemoteMediaClient();
const _castDevice = useCastDevice();
const castState = useCastState();
const devices = useDevices();
const _sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
@@ -32,6 +36,10 @@ export function Chromecast({
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Connection menu state
const [showConnectionMenu, setShowConnectionMenu] = useState(false);
const isConnected = castState === CastState.CONNECTED;
const lastReportedProgressRef = useRef(0);
const discoveryAttempts = useRef(0);
const maxDiscoveryAttempts = 3;
@@ -148,59 +156,92 @@ export function Chromecast({
[Platform.OS],
);
// Handle press - show connection menu when connected, otherwise show cast dialog
const handlePress = useCallback(() => {
if (isConnected) {
if (mediaStatus?.currentItemId) {
// Media is playing - navigate to full player
router.push("/casting-player");
} else {
// Connected but no media - show connection menu
setShowConnectionMenu(true);
}
} else {
// Not connected - show cast dialog
CastContext.showCastDialog();
}
}, [isConnected, mediaStatus?.currentItemId]);
// Handle disconnect from Chromecast
const handleDisconnect = useCallback(async () => {
try {
const sessionManager = GoogleCast.getSessionManager();
await sessionManager.endCurrentSession(true);
} catch (error) {
console.error("[Chromecast] Disconnect error:", error);
}
}, []);
if (Platform.OS === "ios") {
return (
<Pressable
className='mr-4'
onPress={() => {
if (mediaStatus?.currentItemId) {
router.push("/casting-player");
} else {
CastContext.showCastDialog();
}
}}
{...props}
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
</Pressable>
<>
<Pressable className='mr-4' onPress={handlePress} {...props}>
<AndroidCastButton />
<Feather
name='cast'
size={22}
color={isConnected ? "#a855f7" : "white"}
/>
</Pressable>
<ChromecastConnectionMenu
visible={showConnectionMenu}
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
);
}
if (background === "transparent")
return (
<RoundButton
size='large'
className='mr-2'
background={false}
onPress={() => {
if (mediaStatus?.currentItemId) {
router.replace("/casting-player" as any);
} else {
CastContext.showCastDialog();
}
}}
{...props}
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
</RoundButton>
<>
<RoundButton
size='large'
className='mr-2'
background={false}
onPress={handlePress}
{...props}
>
<AndroidCastButton />
<Feather
name='cast'
size={22}
color={isConnected ? "#a855f7" : "white"}
/>
</RoundButton>
<ChromecastConnectionMenu
visible={showConnectionMenu}
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
);
return (
<RoundButton
size='large'
onPress={() => {
if (mediaStatus?.currentItemId) {
router.push("/casting-player");
} else {
CastContext.showCastDialog();
}
}}
{...props}
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
</RoundButton>
<>
<RoundButton size='large' onPress={handlePress} {...props}>
<AndroidCastButton />
<Feather
name='cast'
size={22}
color={isConnected ? "#a855f7" : "white"}
/>
</RoundButton>
<ChromecastConnectionMenu
visible={showConnectionMenu}
onClose={() => setShowConnectionMenu(false)}
onDisconnect={handleDisconnect}
/>
</>
);
}

View File

@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
MediaPlayerState,
MediaStreamType,
PlayServicesState,
useMediaStatus,
@@ -120,9 +121,14 @@ export const PlayButton: React.FC<Props> = ({
},
async (selectedIndex: number | undefined) => {
if (!api) return;
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
// Compare item IDs AND check if media is actually playing (not stopped/idle)
const currentContentId = mediaStatus?.mediaInfo?.contentId;
const isMediaActive =
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
mediaStatus?.playerState === MediaPlayerState.PAUSED ||
mediaStatus?.playerState === MediaPlayerState.BUFFERING;
const isOpeningCurrentlyPlayingMedia =
currentTitle && currentTitle === item?.Name;
isMediaActive && currentContentId && currentContentId === item?.Id;
switch (selectedIndex) {
case 0:

View File

@@ -8,20 +8,27 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Dimensions, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
MediaPlayerState,
useCastDevice,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated";
import Animated, {
SlideInDown,
SlideOutDown,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
import { CASTING_CONSTANTS } from "@/utils/casting/types";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export const CastingMiniPlayer: React.FC = () => {
const api = useAtomValue(apiAtom);
@@ -34,6 +41,23 @@ export const CastingMiniPlayer: React.FC = () => {
return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined;
}, [mediaStatus?.mediaInfo?.customData]);
// Trickplay support - pass currentItem as BaseItemDto or empty object
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
currentItem || ({} as BaseItemDto),
);
const [trickplayTime, setTrickplayTime] = useState({
hours: 0,
minutes: 0,
seconds: 0,
});
const [scrubPercentage, setScrubPercentage] = useState(0);
const isScrubbing = useRef(false);
// Slider shared values
const sliderProgress = useSharedValue(0);
const sliderMin = useSharedValue(0);
const sliderMax = useSharedValue(100);
// Live progress state that updates every second when playing
const [liveProgress, setLiveProgress] = useState(
mediaStatus?.streamPosition || 0,
@@ -65,6 +89,20 @@ export const CastingMiniPlayer: React.FC = () => {
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
// Update slider max value when duration changes
useEffect(() => {
if (duration > 0) {
sliderMax.value = duration;
}
}, [duration, sliderMax]);
// Sync slider progress with live progress (when not scrubbing)
useEffect(() => {
if (!isScrubbing.current && progress >= 0) {
sliderProgress.value = progress;
}
}, [progress, sliderProgress]);
// For episodes, use season poster; for other content, use item poster
const posterUrl = useMemo(() => {
if (!api?.basePath || !currentItem) return null;
@@ -88,11 +126,19 @@ export const CastingMiniPlayer: React.FC = () => {
);
}, [api?.basePath, currentItem]);
if (!castDevice || !currentItem || !mediaStatus) {
// Hide mini player when:
// - No cast device connected
// - No media info (currentItem)
// - No media status
// - Media is stopped (IDLE state)
// - Media is unknown state
const playerState = mediaStatus?.playerState;
const isMediaStopped = playerState === MediaPlayerState.IDLE;
if (!castDevice || !currentItem || !mediaStatus || isMediaStopped) {
return null;
}
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
const protocolColor = "#a855f7"; // Streamyfin purple
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
@@ -124,29 +170,188 @@ export const CastingMiniPlayer: React.FC = () => {
zIndex: 100,
}}
>
<Pressable onPress={handlePress}>
{/* Progress bar */}
<View
style={{
height: 3,
backgroundColor: "#333",
{/* Interactive progress slider with trickplay */}
<View style={{ paddingHorizontal: 8, paddingTop: 4 }}>
<Slider
style={{ width: "100%", height: 20 }}
progress={sliderProgress}
minimumValue={sliderMin}
maximumValue={sliderMax}
theme={{
maximumTrackTintColor: "#333",
minimumTrackTintColor: protocolColor,
bubbleBackgroundColor: protocolColor,
bubbleTextColor: "#fff",
}}
>
<View
style={{
height: "100%",
width: `${progressPercent}%`,
backgroundColor: protocolColor,
}}
/>
</View>
onSlidingStart={() => {
isScrubbing.current = true;
}}
onValueChange={(value) => {
// Calculate trickplay preview
const progressInTicks = msToTicks(value);
calculateTrickplayUrl(progressInTicks);
// Update time display for trickplay bubble
const progressInSeconds = Math.floor(
ticksToSeconds(progressInTicks),
);
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTrickplayTime({ hours, minutes, seconds });
// Track scrub percentage for bubble positioning
if (duration > 0) {
setScrubPercentage(value / duration);
}
}}
onSlidingComplete={(value) => {
isScrubbing.current = false;
// Seek to the position (value is in milliseconds, convert to seconds)
const positionSeconds = value / 1000;
if (remoteMediaClient && duration > 0) {
remoteMediaClient
.seek({ position: positionSeconds })
.catch((error) => {
console.error("[Mini Player] Seek error:", error);
});
}
}}
renderBubble={() => {
// Calculate bubble position with edge clamping
const screenWidth = Dimensions.get("window").width;
const sliderPadding = 8;
const thumbWidth = 10; // matches thumbWidth prop on Slider
const sliderWidth = screenWidth - sliderPadding * 2;
// Adjust thumb position to account for thumb width affecting travel range
const effectiveTrackWidth = sliderWidth - thumbWidth;
const thumbPosition =
thumbWidth / 2 + scrubPercentage * effectiveTrackWidth;
if (!trickPlayUrl || !trickplayInfo) {
// Show simple time bubble when no trickplay
const timeBubbleWidth = 70;
const minLeft = -thumbPosition;
const maxLeft = sliderWidth - thumbPosition - timeBubbleWidth;
const centeredLeft = -timeBubbleWidth / 2;
const clampedLeft = Math.max(
minLeft,
Math.min(maxLeft, centeredLeft),
);
return (
<View
style={{
position: "absolute",
bottom: 12,
left: clampedLeft,
backgroundColor: protocolColor,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
}}
>
<Text
style={{ color: "#fff", fontSize: 11, fontWeight: "600" }}
>
{`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${
trickplayTime.minutes < 10
? `0${trickplayTime.minutes}`
: trickplayTime.minutes
}:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`}
</Text>
</View>
);
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 140; // Smaller preview for mini player
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
// Calculate clamped position for trickplay preview
const minLeft = -thumbPosition;
const maxLeft = sliderWidth - thumbPosition - tileWidth;
const centeredLeft = -tileWidth / 2;
const clampedLeft = Math.max(
minLeft,
Math.min(maxLeft, centeredLeft),
);
return (
<View
style={{
position: "absolute",
bottom: 12,
left: clampedLeft,
width: tileWidth,
alignItems: "center",
}}
>
{/* Trickplay image preview */}
<View
style={{
width: tileWidth,
height: tileHeight,
borderRadius: 6,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
<Image
cachePolicy='memory-disk'
style={{
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
height:
(tileWidth / (trickplayInfo.aspectRatio ?? 1.78)) *
(trickplayInfo.data?.TileHeight ?? 1),
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit='cover'
/>
</View>
{/* Time overlay */}
<View
style={{
position: "absolute",
bottom: 2,
left: 2,
backgroundColor: "rgba(0, 0, 0, 0.7)",
paddingHorizontal: 4,
paddingVertical: 1,
borderRadius: 3,
}}
>
<Text
style={{ color: "#fff", fontSize: 10, fontWeight: "600" }}
>
{`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${
trickplayTime.minutes < 10
? `0${trickplayTime.minutes}`
: trickplayTime.minutes
}:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`}
</Text>
</View>
</View>
);
}}
sliderHeight={3}
thumbWidth={10}
panHitSlop={{ top: 20, bottom: 20, left: 5, right: 5 }}
/>
</View>
<Pressable onPress={handlePress}>
{/* Content */}
<View
style={{
flexDirection: "row",
alignItems: "center",
padding: 12,
paddingTop: 6,
gap: 12,
}}
>

View 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>
);
};

View File

@@ -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>
);
};