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"}
-
-
-
+
-
+
);
};