diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 71c2ffd3..d398b44b 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -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() { - {/* Stop casting button */} + {/* Stop playback button - stops media but stays connected to Chromecast */} { 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 */} - - - { - 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 */} + + { + 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), ); - }} - > - + + return ( + + + {`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${ + trickplayTime.minutes < 10 + ? `0${trickplayTime.minutes}` + : trickplayTime.minutes + }:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`} + + + ); + } + + 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 ( - - - + > + {/* Trickplay image preview */} + + + + {/* Time overlay */} + + + {`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${ + trickplayTime.minutes < 10 + ? `0${trickplayTime.minutes}` + : trickplayTime.minutes + }:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`} + + + + ); + }} + sliderHeight={6} + thumbWidth={16} + panHitSlop={{ top: 30, bottom: 30, left: 10, right: 10 }} + /> {/* 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) => { diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index 372d1a22..75499312 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -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 ( - { - if (mediaStatus?.currentItemId) { - router.push("/casting-player"); - } else { - CastContext.showCastDialog(); - } - }} - {...props} - > - - - + <> + + + + + setShowConnectionMenu(false)} + onDisconnect={handleDisconnect} + /> + ); } if (background === "transparent") return ( - { - if (mediaStatus?.currentItemId) { - router.replace("/casting-player" as any); - } else { - CastContext.showCastDialog(); - } - }} - {...props} - > - - - + <> + + + + + setShowConnectionMenu(false)} + onDisconnect={handleDisconnect} + /> + ); return ( - { - if (mediaStatus?.currentItemId) { - router.push("/casting-player"); - } else { - CastContext.showCastDialog(); - } - }} - {...props} - > - - - + <> + + + + + setShowConnectionMenu(false)} + onDisconnect={handleDisconnect} + /> + ); } diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 084ce942..7915b442 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -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 = ({ }, 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: diff --git a/components/casting/CastingMiniPlayer.tsx b/components/casting/CastingMiniPlayer.tsx index 0d245257..595b122c 100644 --- a/components/casting/CastingMiniPlayer.tsx +++ b/components/casting/CastingMiniPlayer.tsx @@ -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, }} > - - {/* Progress bar */} - + - - + 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 ( + + + {`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${ + trickplayTime.minutes < 10 + ? `0${trickplayTime.minutes}` + : trickplayTime.minutes + }:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`} + + + ); + } + + 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 ( + + {/* Trickplay image preview */} + + + + {/* Time overlay */} + + + {`${trickplayTime.hours > 0 ? `${trickplayTime.hours}:` : ""}${ + trickplayTime.minutes < 10 + ? `0${trickplayTime.minutes}` + : trickplayTime.minutes + }:${trickplayTime.seconds < 10 ? `0${trickplayTime.seconds}` : trickplayTime.seconds}`} + + + + ); + }} + sliderHeight={3} + thumbWidth={10} + panHitSlop={{ top: 20, bottom: 20, left: 5, right: 5 }} + /> + + + {/* Content */} diff --git a/components/chromecast/ChromecastConnectionMenu.tsx b/components/chromecast/ChromecastConnectionMenu.tsx new file mode 100644 index 00000000..6a57e5b1 --- /dev/null +++ b/components/chromecast/ChromecastConnectionMenu.tsx @@ -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; +} + +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 ( + + + + e.stopPropagation()} + > + {/* Header with device name */} + + + + + + + + {castDevice?.friendlyName || "Chromecast"} + + + Connected + + + + + + + + + {/* Volume Control */} + + + Volume + + {isMuted ? "Muted" : `${displayVolume}%`} + + + + + + + + { + 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 }} + /> + + + + + + {/* Disconnect button */} + + + + + Disconnect + + + + + + + + ); +}; diff --git a/components/chromecast/ChromecastDeviceSheet.tsx b/components/chromecast/ChromecastDeviceSheet.tsx index 538d21bf..b55fb8e8 100644 --- a/components/chromecast/ChromecastDeviceSheet.tsx +++ b/components/chromecast/ChromecastDeviceSheet.tsx @@ -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 = ({ }) => { 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(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 = ({ 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 = ({ } }; + // 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 ( = ({ animationType='slide' onRequestClose={onClose} > - + e.stopPropagation()} + onPress={onClose} > - {/* Header */} - e.stopPropagation()} > + {/* Header */} - - - Chromecast - - - - - - - - {/* Device info */} - - - - Device Name - - - {device?.friendlyName || device?.deviceId || "Unknown Device"} - - - {device?.deviceId && ( - - - Device ID - - - {device?.deviceId} - - - )} - {/* Volume control */} - - - Volume - - {Math.round((volume || 0) * 100)}% - - - - - + + Chromecast + + + + + + + + {/* Device info */} + + + + Device Name + + + {device?.friendlyName || "Unknown Device"} + + + {/* Volume control */} + + + Volume + + {isMuted ? "Muted" : `${displayVolume}%`} + + + + {/* Mute button */} + { - 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 }} + > + + + + { + 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 }} + /> + + - + + {/* Disconnect button */} + + + + {isDisconnecting ? "Disconnecting..." : "Stop Casting"} + + - {/* Disconnect button */} - - - - {isDisconnecting ? "Disconnecting..." : "Stop Casting"} - - - + - + ); };