mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-24 21:12:23 +00: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:
@@ -9,13 +9,21 @@ import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { router, Stack } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import GoogleCast, {
|
||||
CastState,
|
||||
MediaPlayerState,
|
||||
MediaStreamType,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useMediaStatus,
|
||||
@@ -34,6 +42,7 @@ import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettin
|
||||
import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useCasting } from "@/hooks/useCasting";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
@@ -44,6 +53,12 @@ import {
|
||||
truncateTitle,
|
||||
} from "@/utils/casting/helpers";
|
||||
import type { CastProtocol } from "@/utils/casting/types";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
export default function CastingPlayerScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -56,7 +71,24 @@ export default function CastingPlayerScreen() {
|
||||
const castState = useCastState();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const castDevice = useCastDevice();
|
||||
useRemoteMediaClient(); // Keep connection active
|
||||
// Keep hook active for connection - used by remoteMediaClient from useCasting
|
||||
useRemoteMediaClient();
|
||||
|
||||
// Shared values for progress slider (must be initialized before any early returns)
|
||||
const sliderProgress = useSharedValue(0);
|
||||
const sliderMin = useSharedValue(0);
|
||||
const sliderMax = useSharedValue(100);
|
||||
const isScrubbing = useRef(false);
|
||||
|
||||
// Trickplay time display
|
||||
const [trickplayTime, setTrickplayTime] = useState({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
|
||||
// Track scrub percentage for trickplay bubble positioning
|
||||
const [scrubPercentage, setScrubPercentage] = useState(0);
|
||||
|
||||
// Live progress tracking - update every second
|
||||
const [liveProgress, setLiveProgress] = useState(0);
|
||||
@@ -161,13 +193,31 @@ export default function CastingPlayerScreen() {
|
||||
const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
||||
const currentDevice = castDevice?.friendlyName ?? null;
|
||||
|
||||
// Trickplay for seeking preview - use fetched item with full data
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
fetchedItem ?? ({} as BaseItemDto),
|
||||
);
|
||||
|
||||
// Update slider max when duration changes
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
sliderMax.value = duration * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [duration, sliderMax]);
|
||||
|
||||
// Update slider progress when not scrubbing
|
||||
useEffect(() => {
|
||||
if (!isScrubbing.current && progress > 0) {
|
||||
sliderProgress.value = progress * 1000; // Convert to milliseconds
|
||||
}
|
||||
}, [progress, sliderProgress]);
|
||||
|
||||
// Only use casting controls if we have a current item to avoid "No session" errors
|
||||
const castingControls = useCasting(currentItem);
|
||||
const {
|
||||
togglePlayPause,
|
||||
skipForward,
|
||||
skipBackward,
|
||||
stop,
|
||||
setVolume,
|
||||
volume,
|
||||
remoteMediaClient,
|
||||
@@ -177,7 +227,6 @@ export default function CastingPlayerScreen() {
|
||||
togglePlayPause: async () => {},
|
||||
skipForward: async () => {},
|
||||
skipBackward: async () => {},
|
||||
stop: async () => {},
|
||||
setVolume: async () => {},
|
||||
volume: 1,
|
||||
remoteMediaClient: null,
|
||||
@@ -200,6 +249,128 @@ export default function CastingPlayerScreen() {
|
||||
>(null);
|
||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
|
||||
|
||||
// Function to reload media with new audio/subtitle/quality settings
|
||||
const reloadWithSettings = useCallback(
|
||||
async (options: {
|
||||
audioIndex?: number;
|
||||
subtitleIndex?: number | null;
|
||||
bitrateValue?: number;
|
||||
}) => {
|
||||
if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) {
|
||||
console.warn("[Casting Player] Cannot reload - missing required data");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Save current playback position
|
||||
const currentPosition = mediaStatus?.streamPosition ?? 0;
|
||||
console.log(
|
||||
"[Casting Player] Reloading stream at position:",
|
||||
currentPosition,
|
||||
);
|
||||
|
||||
// Get new stream URL with updated settings
|
||||
const enableH265 = settings.enableH265ForChromecast;
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item: currentItem,
|
||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||
startTimeTicks: Math.floor(currentPosition * 10000000), // Convert seconds to ticks
|
||||
userId: user.Id,
|
||||
audioStreamIndex:
|
||||
options.audioIndex ?? selectedAudioTrackIndex ?? undefined,
|
||||
subtitleStreamIndex: options.subtitleIndex ?? undefined,
|
||||
maxStreamingBitrate: options.bitrateValue,
|
||||
});
|
||||
|
||||
if (!data?.url) {
|
||||
console.error("[Casting Player] Failed to get stream URL");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Casting Player] Reloading with new URL:", data.url);
|
||||
|
||||
// Reload media with new URL
|
||||
await remoteMediaClient.loadMedia({
|
||||
mediaInfo: {
|
||||
contentId: currentItem.Id,
|
||||
contentUrl: data.url,
|
||||
contentType: "video/mp4",
|
||||
streamType: MediaStreamType.BUFFERED,
|
||||
streamDuration: currentItem.RunTimeTicks
|
||||
? currentItem.RunTimeTicks / 10000000
|
||||
: undefined,
|
||||
customData: currentItem,
|
||||
metadata:
|
||||
currentItem.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow",
|
||||
title: currentItem.Name || "",
|
||||
episodeNumber: currentItem.IndexNumber || 0,
|
||||
seasonNumber: currentItem.ParentIndexNumber || 0,
|
||||
seriesTitle: currentItem.SeriesName || "",
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item: currentItem,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: currentItem.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: currentItem.Name || "",
|
||||
subtitle: currentItem.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item: currentItem,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: currentItem.Name || "",
|
||||
subtitle: currentItem.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item: currentItem,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
startTime: currentPosition, // Resume at same position
|
||||
});
|
||||
|
||||
console.log("[Casting Player] Stream reloaded successfully");
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Failed to reload stream:", error);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
currentItem,
|
||||
remoteMediaClient,
|
||||
mediaStatus?.streamPosition,
|
||||
settings.enableH265ForChromecast,
|
||||
selectedAudioTrackIndex,
|
||||
],
|
||||
);
|
||||
|
||||
// Fetch season data for season poster
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -315,6 +486,9 @@ export default function CastingPlayerScreen() {
|
||||
}, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]);
|
||||
|
||||
// Auto-select stereo audio track for better Chromecast compatibility
|
||||
// Note: This only updates the UI state. The actual audio track change requires
|
||||
// regenerating the stream URL, which would be disruptive on initial load.
|
||||
// The user can manually switch audio tracks if needed.
|
||||
useEffect(() => {
|
||||
if (!remoteMediaClient || !mediaStatus?.mediaInfo) return;
|
||||
|
||||
@@ -322,23 +496,26 @@ export default function CastingPlayerScreen() {
|
||||
(t) => t.index === selectedAudioTrackIndex,
|
||||
);
|
||||
|
||||
// If current track is 5.1+ audio, try to switch to stereo
|
||||
// If current track is 5.1+ audio, suggest stereo in the UI
|
||||
if (currentTrack && (currentTrack.channels || 0) > 2) {
|
||||
const stereoTrack = availableAudioTracks.find((t) => t.channels === 2);
|
||||
if (stereoTrack && stereoTrack.index !== selectedAudioTrackIndex) {
|
||||
console.log(
|
||||
"[Audio] Switching from 5.1 to stereo for better compatibility:",
|
||||
"[Audio] Note: 5.1 audio detected. Stereo available:",
|
||||
currentTrack.displayTitle,
|
||||
"->",
|
||||
stereoTrack.displayTitle,
|
||||
);
|
||||
// Auto-select stereo in UI (user can manually trigger reload)
|
||||
setSelectedAudioTrackIndex(stereoTrack.index);
|
||||
remoteMediaClient
|
||||
.setActiveTrackIds([stereoTrack.index])
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
}, [mediaStatus?.mediaInfo, availableAudioTracks, remoteMediaClient]);
|
||||
}, [
|
||||
mediaStatus?.mediaInfo,
|
||||
availableAudioTracks,
|
||||
remoteMediaClient,
|
||||
selectedAudioTrackIndex,
|
||||
]);
|
||||
|
||||
// Fetch episodes for TV shows
|
||||
useEffect(() => {
|
||||
@@ -398,10 +575,6 @@ export default function CastingPlayerScreen() {
|
||||
const translateY = useSharedValue(0);
|
||||
const context = useSharedValue({ y: 0 });
|
||||
|
||||
// Progress bar swipe gesture
|
||||
const progressGestureContext = useSharedValue({ startValue: 0 });
|
||||
const isSeeking = useSharedValue(false);
|
||||
|
||||
const dismissModal = useCallback(() => {
|
||||
// Navigate immediately without animation to prevent crashes
|
||||
if (router.canGoBack()) {
|
||||
@@ -441,47 +614,6 @@ export default function CastingPlayerScreen() {
|
||||
}
|
||||
});
|
||||
|
||||
// Progress bar pan gesture for seeking
|
||||
const progressPanGesture = Gesture.Pan()
|
||||
.onBegin(() => {
|
||||
isSeeking.value = true;
|
||||
progressGestureContext.value = { startValue: liveProgress };
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (!duration) return;
|
||||
// Calculate seek delta based on screen width (more sensitive)
|
||||
const deltaSeconds = event.translationX / 5; // Adjust sensitivity
|
||||
const newPosition = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
progressGestureContext.value.startValue + deltaSeconds,
|
||||
),
|
||||
);
|
||||
// Update live progress for immediate UI feedback (must use runOnJS)
|
||||
runOnJS(setLiveProgress)(newPosition);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isSeeking.value = false;
|
||||
// Calculate final position from gesture context
|
||||
if (remoteMediaClient && duration) {
|
||||
const deltaSeconds = event.translationX / 5;
|
||||
const finalPosition = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
progressGestureContext.value.startValue + deltaSeconds,
|
||||
),
|
||||
);
|
||||
// Use runOnJS to call the async function
|
||||
runOnJS((pos: number) => {
|
||||
remoteMediaClient.seek({ position: pos }).catch((error) => {
|
||||
console.error("[Casting Player] Seek error:", error);
|
||||
});
|
||||
})(finalPosition);
|
||||
}
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
@@ -528,11 +660,6 @@ export default function CastingPlayerScreen() {
|
||||
currentItem?.ImageTags?.Primary,
|
||||
]);
|
||||
|
||||
const progressPercent = useMemo(
|
||||
() => (duration > 0 ? (progress / duration) * 100 : 0),
|
||||
[progress, duration],
|
||||
);
|
||||
|
||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
||||
|
||||
const _showNextEpisode = useMemo(() => {
|
||||
@@ -605,6 +732,7 @@ export default function CastingPlayerScreen() {
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
presentation: "fullScreenModal",
|
||||
animation: "slide_from_bottom",
|
||||
}}
|
||||
@@ -697,7 +825,7 @@ export default function CastingPlayerScreen() {
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 25,
|
||||
fontSize: 20,
|
||||
fontWeight: "700",
|
||||
textAlign: "center",
|
||||
marginBottom: 6,
|
||||
@@ -947,15 +1075,16 @@ export default function CastingPlayerScreen() {
|
||||
<Ionicons name='play-skip-forward' size={22} color='white' />
|
||||
</Pressable>
|
||||
|
||||
{/* Stop casting button */}
|
||||
{/* Stop playback button - stops media but stays connected to Chromecast */}
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
try {
|
||||
// End the casting session and stop the receiver
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
await sessionManager.endCurrentSession(true);
|
||||
// Stop the current media playback (don't disconnect from Chromecast)
|
||||
if (remoteMediaClient) {
|
||||
await remoteMediaClient.stop();
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
// Navigate back/close the player (mini player will disappear since no media is playing)
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
@@ -963,10 +1092,10 @@ export default function CastingPlayerScreen() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Casting Player] Error disconnecting:",
|
||||
"[Casting Player] Error stopping playback:",
|
||||
error,
|
||||
);
|
||||
// Try to navigate anyway
|
||||
// Navigate anyway
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
@@ -999,46 +1128,195 @@ export default function CastingPlayerScreen() {
|
||||
zIndex: 98,
|
||||
}}
|
||||
>
|
||||
{/* Progress slider - interactive with pan gesture and tap */}
|
||||
<View style={{ marginBottom: 16, marginTop: 8 }}>
|
||||
<GestureDetector gesture={progressPanGesture}>
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
if (!remoteMediaClient || !duration) return;
|
||||
// Get the layout to calculate percentage
|
||||
e.currentTarget.measure(
|
||||
(_x, _y, width, _height, pageX, _pageY) => {
|
||||
const touchX = e.nativeEvent.pageX - pageX;
|
||||
const percentage = Math.max(
|
||||
0,
|
||||
Math.min(1, touchX / width),
|
||||
);
|
||||
const newPosition = percentage * duration;
|
||||
remoteMediaClient
|
||||
.seek({ position: newPosition })
|
||||
.catch(console.error);
|
||||
},
|
||||
{/* Progress slider with trickplay preview */}
|
||||
<View style={{ marginTop: 8, height: 40 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={sliderProgress}
|
||||
minimumValue={sliderMin}
|
||||
maximumValue={sliderMax}
|
||||
theme={{
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: protocolColor,
|
||||
bubbleBackgroundColor: protocolColor,
|
||||
bubbleTextColor: "#fff",
|
||||
}}
|
||||
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
|
||||
const durationMs = duration * 1000;
|
||||
if (durationMs > 0) {
|
||||
setScrubPercentage(value / durationMs);
|
||||
}
|
||||
}}
|
||||
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("[Casting Player] Seek error:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
renderBubble={() => {
|
||||
// Calculate bubble position with edge clamping
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
const containerPadding = 20; // left/right padding of slider container (matches style)
|
||||
const thumbWidth = 16; // matches thumbWidth prop on Slider
|
||||
const sliderWidth = screenWidth - containerPadding * 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 = 80;
|
||||
// Clamp position so bubble stays on screen
|
||||
// minLeft prevents going off left edge, maxLeft prevents going off right edge
|
||||
const minLeft = -thumbPosition;
|
||||
const maxLeft =
|
||||
sliderWidth - thumbPosition - timeBubbleWidth;
|
||||
const centeredLeft = -timeBubbleWidth / 2;
|
||||
const clampedLeft = Math.max(
|
||||
minLeft,
|
||||
Math.min(maxLeft, centeredLeft),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: 6,
|
||||
backgroundColor: "#333",
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 20,
|
||||
left: clampedLeft,
|
||||
backgroundColor: protocolColor,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
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 = 220; // Larger preview for casting player
|
||||
const tileHeight =
|
||||
tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
|
||||
|
||||
// Calculate clamped position for trickplay preview
|
||||
// minLeft: furthest left (when thumb is at left edge)
|
||||
// maxLeft: furthest right (when thumb is at right edge)
|
||||
const minLeft = -thumbPosition;
|
||||
const maxLeft = sliderWidth - thumbPosition - tileWidth;
|
||||
const centeredLeft = -tileWidth / 2;
|
||||
const clampedLeft = Math.max(
|
||||
minLeft,
|
||||
Math.min(maxLeft, centeredLeft),
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: protocolColor,
|
||||
position: "absolute",
|
||||
bottom: 20,
|
||||
left: clampedLeft,
|
||||
width: tileWidth,
|
||||
alignItems: "center",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</GestureDetector>
|
||||
>
|
||||
{/* Trickplay image preview */}
|
||||
<View
|
||||
style={{
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
borderRadius: 8,
|
||||
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: 4,
|
||||
left: 4,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
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={6}
|
||||
thumbWidth={16}
|
||||
panHitSlop={{ top: 30, bottom: 30, left: 10, right: 10 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time display */}
|
||||
@@ -1162,9 +1440,11 @@ export default function CastingPlayerScreen() {
|
||||
}
|
||||
onDisconnect={async () => {
|
||||
try {
|
||||
await stop();
|
||||
// End the casting session and disconnect completely
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
await sessionManager.endCurrentSession(true);
|
||||
setShowDeviceSheet(false);
|
||||
// Close player immediately after stopping
|
||||
// Close player immediately after disconnecting
|
||||
setTimeout(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
@@ -1173,7 +1453,10 @@ export default function CastingPlayerScreen() {
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error("[Casting Player] Error stopping:", error);
|
||||
console.error(
|
||||
"[Casting Player] Error disconnecting from Chromecast:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}}
|
||||
volume={volume}
|
||||
@@ -1206,8 +1489,9 @@ export default function CastingPlayerScreen() {
|
||||
})}
|
||||
selectedMediaSource={availableMediaSources[0] || null}
|
||||
onMediaSourceChange={(source) => {
|
||||
// TODO: Requires reloading media with new source URL
|
||||
// Reload stream with new bitrate
|
||||
console.log("Changed media source:", source);
|
||||
reloadWithSettings({ bitrateValue: source.bitrate });
|
||||
}}
|
||||
audioTracks={availableAudioTracks}
|
||||
selectedAudioTrack={
|
||||
@@ -1219,10 +1503,8 @@ export default function CastingPlayerScreen() {
|
||||
}
|
||||
onAudioTrackChange={(track) => {
|
||||
setSelectedAudioTrackIndex(track.index);
|
||||
// Set active tracks using RemoteMediaClient
|
||||
remoteMediaClient
|
||||
?.setActiveTrackIds([track.index])
|
||||
.catch(console.error);
|
||||
// Reload stream with new audio track
|
||||
reloadWithSettings({ audioIndex: track.index });
|
||||
}}
|
||||
subtitleTracks={availableSubtitleTracks}
|
||||
selectedSubtitleTrack={
|
||||
@@ -1234,14 +1516,8 @@ export default function CastingPlayerScreen() {
|
||||
}
|
||||
onSubtitleTrackChange={(track) => {
|
||||
setSelectedSubtitleTrackIndex(track?.index ?? null);
|
||||
if (track) {
|
||||
remoteMediaClient
|
||||
?.setActiveTrackIds([track.index])
|
||||
.catch(console.error);
|
||||
} else {
|
||||
// Disable subtitles
|
||||
remoteMediaClient?.setActiveTrackIds([]).catch(console.error);
|
||||
}
|
||||
// Reload stream with new subtitle track
|
||||
reloadWithSettings({ subtitleIndex: track?.index ?? null });
|
||||
}}
|
||||
playbackSpeed={currentPlaybackSpeed}
|
||||
onPlaybackSpeedChange={(speed) => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
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