fix: Refactors Chromecast casting player

Refactors the Chromecast casting player for better compatibility, UI improvements, and stability.

- Adds auto-selection of stereo audio tracks for improved Chromecast compatibility
- Refactors episode list to filter out virtual episodes and allow season selection
- Improves UI layout and styling
- Removes connection quality indicator
- Fixes progress reporting to Jellyfin
- Updates volume control to use CastSession for device volume
This commit is contained in:
Uruk
2026-02-04 21:03:49 +01:00
parent bc08df903f
commit f6a47b9867
9 changed files with 569 additions and 367 deletions

View File

@@ -13,7 +13,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import {
import GoogleCast, {
CastState,
MediaPlayerState,
useCastDevice,
@@ -39,7 +39,6 @@ import { useSettings } from "@/utils/atoms/settings";
import {
calculateEndingTime,
formatTime,
getConnectionQuality,
getPosterUrl,
shouldShowNextEpisodeCountdown,
truncateTitle,
@@ -188,7 +187,6 @@ export default function CastingPlayerScreen() {
const [showEpisodeList, setShowEpisodeList] = useState(false);
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
const [episodes, setEpisodes] = useState<BaseItemDto[]>([]);
const [nextEpisode, setNextEpisode] = useState<BaseItemDto | null>(null);
const [seasonData, setSeasonData] = useState<BaseItemDto | null>(null);
@@ -316,6 +314,32 @@ export default function CastingPlayerScreen() {
return variants;
}, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]);
// Auto-select stereo audio track for better Chromecast compatibility
useEffect(() => {
if (!remoteMediaClient || !mediaStatus?.mediaInfo) return;
const currentTrack = availableAudioTracks.find(
(t) => t.index === selectedAudioTrackIndex,
);
// If current track is 5.1+ audio, try to switch to stereo
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:",
currentTrack.displayTitle,
"->",
stereoTrack.displayTitle,
);
setSelectedAudioTrackIndex(stereoTrack.index);
remoteMediaClient
.setActiveTrackIds([stereoTrack.index])
.catch(console.error);
}
}
}, [mediaStatus?.mediaInfo, availableAudioTracks, remoteMediaClient]);
// Fetch episodes for TV shows
useEffect(() => {
if (currentItem?.Type !== "Episode" || !currentItem.SeriesId || !api)
@@ -324,9 +348,10 @@ export default function CastingPlayerScreen() {
const fetchEpisodes = async () => {
try {
const tvShowsApi = getTvShowsApi(api);
// Fetch ALL episodes from ALL seasons by removing seasonId filter
const response = await tvShowsApi.getEpisodes({
seriesId: currentItem.SeriesId!,
seasonId: currentItem.SeasonId || undefined,
// Don't filter by seasonId - get all seasons
userId: api.accessToken ? undefined : "",
});
@@ -475,8 +500,8 @@ export default function CastingPlayerScreen() {
{ imageItemId, seasonImageTag },
);
return seasonImageTag
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=390&fillWidth=260&quality=96&tag=${seasonImageTag}`
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=390&fillWidth=260&quality=96`;
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`;
}
// Fallback to item poster for non-episodes or if season data not loaded
@@ -510,27 +535,6 @@ export default function CastingPlayerScreen() {
const protocolColor = "#a855f7"; // Streamyfin purple
const connectionQuality = useMemo(() => {
const bitrate = availableMediaSources[0]?.bitrate;
return getConnectionQuality(bitrate);
}, [availableMediaSources]);
// Get quality indicator color
const qualityColor = useMemo(() => {
switch (connectionQuality) {
case "excellent":
return "#22c55e"; // green
case "good":
return "#eab308"; // yellow
case "fair":
return "#f59e0b"; // orange
case "poor":
return "#ef4444"; // red
default:
return protocolColor;
}
}, [connectionQuality, protocolColor]);
const _showNextEpisode = useMemo(() => {
if (currentItem?.Type !== "Episode" || !nextEpisode) return false;
const remaining = duration - progress;
@@ -659,14 +663,12 @@ export default function CastingPlayerScreen() {
<Text
style={{
color: protocolColor,
fontSize: 12,
fontSize: 14,
fontWeight: "500",
}}
>
{currentDevice || "Unknown Device"}
</Text>
{/* Connection quality indicator with color */}
<Ionicons name='cellular' size={14} color={qualityColor} />
</Pressable>
<Pressable
@@ -681,7 +683,7 @@ export default function CastingPlayerScreen() {
<View
style={{
position: "absolute",
top: insets.top + 40,
top: insets.top + 50,
left: 0,
right: 0,
zIndex: 95,
@@ -695,7 +697,7 @@ export default function CastingPlayerScreen() {
<Text
style={{
color: "white",
fontSize: 22,
fontSize: 25,
fontWeight: "700",
textAlign: "center",
marginBottom: 6,
@@ -711,7 +713,7 @@ export default function CastingPlayerScreen() {
<Text
style={{
color: "#999",
fontSize: 14,
fontSize: 15,
textAlign: "center",
}}
>
@@ -727,12 +729,12 @@ export default function CastingPlayerScreen() {
<ScrollView
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: insets.top + 140,
paddingTop: insets.top + 160,
paddingBottom: insets.bottom + 500,
}}
showsVerticalScrollIndicator={false}
>
{/* Poster with buffering overlay - reduced size */}
{/* Poster with buffering overlay */}
<View
style={{
alignItems: "center",
@@ -741,8 +743,8 @@ export default function CastingPlayerScreen() {
>
<View
style={{
width: 260,
height: 390,
width: 280,
height: 420,
borderRadius: 12,
overflow: "hidden",
position: "relative",
@@ -859,125 +861,133 @@ export default function CastingPlayerScreen() {
)}
</View>
</View>
</ScrollView>
{/* Spacer to push buttons down */}
<View style={{ height: 40 }} />
{/* 4-button control row for episodes */}
{currentItem.Type === "Episode" && (
<View
{/* Fixed 4-button control row for episodes - positioned independently */}
{currentItem.Type === "Episode" && (
<View
style={{
position: "absolute",
bottom: insets.bottom + 200,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 16,
paddingHorizontal: 20,
}}
>
{/* Episodes button */}
<Pressable
onPress={() => setShowEpisodeList(true)}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 16,
marginBottom: 40,
paddingHorizontal: 20,
}}
>
{/* Episodes button */}
<Pressable
onPress={() => setShowEpisodeList(true)}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='list' size={22} color='white' />
</Pressable>
<Ionicons name='list' size={22} color='white' />
</Pressable>
{/* Previous episode button */}
<Pressable
onPress={async () => {
const currentIndex = episodes.findIndex(
(ep) => ep.Id === currentItem.Id,
);
if (currentIndex > 0 && remoteMediaClient) {
const previousEp = episodes[currentIndex - 1];
console.log("Previous episode:", previousEp.Name);
}
}}
disabled={
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
{/* Previous episode button */}
<Pressable
onPress={async () => {
const currentIndex = episodes.findIndex(
(ep) => ep.Id === currentItem.Id,
);
if (currentIndex > 0 && remoteMediaClient) {
const previousEp = episodes[currentIndex - 1];
console.log("Previous episode:", previousEp.Name);
}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
opacity:
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
? 0.4
: 1,
}}
>
<Ionicons name='play-skip-back' size={22} color='white' />
</Pressable>
}}
disabled={
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
opacity:
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0
? 0.4
: 1,
}}
>
<Ionicons name='play-skip-back' size={22} color='white' />
</Pressable>
{/* Next episode button */}
<Pressable
onPress={async () => {
if (nextEpisode && remoteMediaClient) {
console.log("Next episode:", nextEpisode.Name);
}
}}
disabled={!nextEpisode}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
opacity: nextEpisode ? 1 : 0.4,
}}
>
<Ionicons name='play-skip-forward' size={22} color='white' />
</Pressable>
{/* Next episode button */}
<Pressable
onPress={async () => {
if (nextEpisode && remoteMediaClient) {
console.log("Next episode:", nextEpisode.Name);
}
}}
disabled={!nextEpisode}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
opacity: nextEpisode ? 1 : 0.4,
}}
>
<Ionicons name='play-skip-forward' size={22} color='white' />
</Pressable>
{/* Stop casting button */}
<Pressable
onPress={async () => {
try {
await stop();
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
} catch (error) {
console.error("[Casting Player] Error stopping:", error);
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
{/* Stop casting button */}
<Pressable
onPress={async () => {
try {
// End the casting session and stop the receiver
const sessionManager = GoogleCast.getSessionManager();
await sessionManager.endCurrentSession(true);
// Navigate back
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='stop-circle' size={22} color='white' />
</Pressable>
</View>
)}
</ScrollView>
} catch (error) {
console.error(
"[Casting Player] Error disconnecting:",
error,
);
// Try to navigate anyway
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}
}}
style={{
flex: 1,
backgroundColor: "#1a1a1a",
padding: 12,
borderRadius: 12,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='stop-circle' size={22} color='white' />
</Pressable>
</View>
)}
{/* Fixed bottom controls area */}
<View
@@ -1150,8 +1160,6 @@ export default function CastingPlayerScreen() {
} as any)
: null
}
connectionQuality={connectionQuality}
bitrate={availableMediaSources[0]?.bitrate}
onDisconnect={async () => {
try {
await stop();
@@ -1172,7 +1180,6 @@ export default function CastingPlayerScreen() {
onVolumeChange={async (vol) => {
setVolume(vol);
}}
showTechnicalInfo={showTechnicalInfo}
/>
<ChromecastEpisodeList
@@ -1241,10 +1248,6 @@ export default function CastingPlayerScreen() {
setCurrentPlaybackSpeed(speed);
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
}}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={() => {
setShowTechnicalInfo(!showTechnicalInfo);
}}
/>
</Animated.View>
</GestureDetector>

View File

@@ -4,58 +4,111 @@
*/
import { Ionicons } from "@expo/vector-icons";
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 from "react";
import React, { useEffect, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import {
MediaPlayerState,
useCastDevice,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useCasting } from "@/hooks/useCasting";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
formatTime,
getPosterUrl,
getProtocolIcon,
getProtocolName,
} from "@/utils/casting/helpers";
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
import { CASTING_CONSTANTS } from "@/utils/casting/types";
export const CastingMiniPlayer: React.FC = () => {
const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets();
const {
isConnected,
protocol,
currentItem,
currentDevice,
progress,
duration,
isPlaying,
togglePlayPause,
} = useCasting(null);
const castDevice = useCastDevice();
const mediaStatus = useMediaStatus();
const remoteMediaClient = useRemoteMediaClient();
if (!isConnected || !currentItem || !protocol) {
const currentItem = useMemo(() => {
return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined;
}, [mediaStatus?.mediaInfo?.customData]);
// Live progress state that updates every second when playing
const [liveProgress, setLiveProgress] = useState(
mediaStatus?.streamPosition || 0,
);
// Sync live progress with mediaStatus and poll every second when playing
useEffect(() => {
if (mediaStatus?.streamPosition) {
setLiveProgress(mediaStatus.streamPosition);
}
// Update every second when playing
const interval = setInterval(() => {
if (
mediaStatus?.playerState === MediaPlayerState.PLAYING &&
mediaStatus?.streamPosition !== undefined
) {
setLiveProgress((prev) => prev + 1);
} else if (mediaStatus?.streamPosition !== undefined) {
// Sync with actual position when paused/buffering
setLiveProgress(mediaStatus.streamPosition);
}
}, 1000);
return () => clearInterval(interval);
}, [mediaStatus?.playerState, mediaStatus?.streamPosition]);
const progress = liveProgress * 1000; // Convert to ms
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
// For episodes, use season poster; for other content, use item poster
const posterUrl = useMemo(() => {
if (!api?.basePath || !currentItem) return null;
if (
currentItem.Type === "Episode" &&
currentItem.SeriesId &&
currentItem.ParentIndexNumber
) {
// Build season poster URL using SeriesId and season number
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96&tag=${currentItem.SeasonId}`;
}
// For non-episodes, use item's own poster
return getPosterUrl(
api.basePath,
currentItem.Id,
currentItem.ImageTags?.Primary,
80,
120,
);
}, [api?.basePath, currentItem]);
if (!castDevice || !currentItem || !mediaStatus) {
return null;
}
const posterUrl = getPosterUrl(
api?.basePath,
currentItem.Id,
currentItem.ImageTags?.Primary,
80,
120,
);
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
const protocolColor = "#a855f7"; // Streamyfin purple
const TAB_BAR_HEIGHT = 49; // Standard tab bar height
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
const handlePress = () => {
router.push("/casting-player");
};
const handleTogglePlayPause = (e: any) => {
e.stopPropagation();
if (isPlaying) {
remoteMediaClient?.pause();
} else {
remoteMediaClient?.play();
}
};
return (
<Animated.View
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
@@ -141,11 +194,7 @@ export const CastingMiniPlayer: React.FC = () => {
marginTop: 2,
}}
>
<Ionicons
name={getProtocolIcon(protocol)}
size={12}
color={protocolColor}
/>
<Ionicons name='tv' size={12} color={protocolColor} />
<Text
style={{
color: protocolColor,
@@ -153,7 +202,7 @@ export const CastingMiniPlayer: React.FC = () => {
}}
numberOfLines={1}
>
{currentDevice?.name || getProtocolName(protocol)}
{castDevice.friendlyName || "Chromecast"}
</Text>
<Text
style={{
@@ -167,17 +216,7 @@ export const CastingMiniPlayer: React.FC = () => {
</View>
{/* Play/Pause button */}
<Pressable
onPress={(e) => {
e.stopPropagation();
if (isConnected && protocol) {
togglePlayPause();
}
}}
style={{
padding: 8,
}}
>
<Pressable onPress={handleTogglePlayPause} style={{ padding: 8 }}>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={28}

View File

@@ -8,6 +8,7 @@ import React, { useEffect, 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 { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -19,9 +20,6 @@ interface ChromecastDeviceSheetProps {
onDisconnect: () => Promise<void>;
volume?: number;
onVolumeChange?: (volume: number) => Promise<void>;
showTechnicalInfo?: boolean;
connectionQuality?: "excellent" | "good" | "fair" | "poor";
bitrate?: number;
}
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
@@ -31,19 +29,34 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
onDisconnect,
volume = 0.5,
onVolumeChange,
showTechnicalInfo = false,
connectionQuality = "good",
bitrate,
}) => {
const insets = useSafeAreaInsets();
const [isDisconnecting, setIsDisconnecting] = useState(false);
const volumeValue = useSharedValue(volume * 100);
const minimumValue = useSharedValue(0);
const maximumValue = useSharedValue(100);
const castSession = useCastSession();
const remoteMediaClient = useRemoteMediaClient();
// Sync volume slider with prop changes
// Sync volume slider with prop changes (updates from physical buttons)
useEffect(() => {
volumeValue.value = volume * 100;
}, [volume, volumeValue]);
// Poll for volume updates when sheet is visible to catch physical button changes
useEffect(() => {
if (!visible || !remoteMediaClient) return;
// Request status update to get latest volume from device
const interval = setInterval(() => {
remoteMediaClient.requestStatus().catch(() => {
// Ignore errors - device might be disconnected
});
}, 1000);
return () => clearInterval(interval);
}, [visible, remoteMediaClient]);
const handleDisconnect = async () => {
setIsDisconnecting(true);
try {
@@ -57,8 +70,19 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
};
const handleVolumeComplete = async (value: number) => {
if (onVolumeChange) {
await onVolumeChange(value / 100);
const newVolume = value / 100;
try {
// Use CastSession.setVolume for DEVICE volume control
// This works even when no media is playing, unlike setStreamVolume
if (castSession) {
castSession.setVolume(newVolume);
console.log("[Volume] Set device volume via CastSession:", newVolume);
} else if (onVolumeChange) {
// Fallback to prop method if session not available
await onVolumeChange(newVolume);
}
} catch (error) {
console.error("[Volume] Error setting volume:", error);
}
};
@@ -120,61 +144,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
{device?.friendlyName || device?.deviceId || "Unknown Device"}
</Text>
</View>
{/* Connection Quality */}
<View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 8 }}>
Connection Quality
</Text>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<View
style={{
width: 12,
height: 12,
borderRadius: 6,
backgroundColor:
connectionQuality === "excellent"
? "#10b981"
: connectionQuality === "good"
? "#fbbf24"
: connectionQuality === "fair"
? "#f97316"
: "#ef4444",
}}
/>
<Text
style={{
color:
connectionQuality === "excellent"
? "#10b981"
: connectionQuality === "good"
? "#fbbf24"
: connectionQuality === "fair"
? "#f97316"
: "#ef4444",
fontSize: 14,
fontWeight: "600",
textTransform: "capitalize",
}}
>
{connectionQuality}
</Text>
</View>
{bitrate && (
<Text style={{ color: "#999", fontSize: 12, marginTop: 4 }}>
Bitrate: {(bitrate / 1000000).toFixed(1)} Mbps
{connectionQuality === "poor" &&
" (Low bitrate may cause buffering)"}
{connectionQuality === "fair" && " (Moderate quality)"}
{connectionQuality === "good" && " (Good quality)"}
{connectionQuality === "excellent" && " (Maximum quality)"}
</Text>
)}
</View>
{device?.deviceId && showTechnicalInfo && (
{device?.deviceId && (
<View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
Device ID
@@ -183,11 +153,10 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
style={{ color: "white", fontSize: 14 }}
numberOfLines={1}
>
{device.deviceId}
{device?.deviceId}
</Text>
</View>
)}
{/* Volume control */}
<View style={{ marginBottom: 24 }}>
<View
@@ -211,8 +180,8 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<Slider
style={{ width: "100%", height: 40 }}
progress={volumeValue}
minimumValue={useSharedValue(0)}
maximumValue={useSharedValue(100)}
minimumValue={minimumValue}
maximumValue={maximumValue}
theme={{
disableMinTrackTintColor: "#333",
maximumTrackTintColor: "#333",
@@ -231,13 +200,11 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
}}
onSlidingComplete={handleVolumeComplete}
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
disable={false}
/>
</View>
<Ionicons name='volume-high' size={20} color='#999' />
</View>
</View>
{/* Disconnect button */}
<Pressable
onPress={handleDisconnect}
@@ -253,7 +220,12 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
opacity: isDisconnecting ? 0.5 : 1,
}}
>
<Ionicons name='power' size={20} color='white' />
<Ionicons
name='power'
size={20}
color='white'
style={{ marginTop: 2 }}
/>
<Text style={{ color: "white", fontSize: 16, fontWeight: "600" }}>
{isDisconnecting ? "Disconnecting..." : "Stop Casting"}
</Text>

View File

@@ -7,8 +7,8 @@ import { Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import React, { useEffect, useRef } from "react";
import { FlatList, Modal, Pressable, View } from "react-native";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { truncateTitle } from "@/utils/casting/helpers";
@@ -33,10 +33,47 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}) => {
const insets = useSafeAreaInsets();
const flatListRef = useRef<FlatList>(null);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
// Get unique seasons from episodes
const seasons = useMemo(() => {
const seasonSet = new Set<number>();
for (const ep of episodes) {
if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) {
seasonSet.add(ep.ParentIndexNumber);
}
}
return Array.from(seasonSet).sort((a, b) => a - b);
}, [episodes]);
// Filter episodes by selected season and exclude virtual episodes
const filteredEpisodes = useMemo(() => {
let eps = episodes;
// Filter by season if selected
if (selectedSeason !== null) {
eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason);
}
// Filter out virtual episodes (episodes without actual video files)
// LocationType === "Virtual" means the episode doesn't have a media file
eps = eps.filter((ep) => ep.LocationType !== "Virtual");
return eps;
}, [episodes, selectedSeason]);
// Set initial season to current episode's season
useEffect(() => {
if (currentItem?.ParentIndexNumber !== undefined) {
setSelectedSeason(currentItem.ParentIndexNumber);
}
}, [currentItem]);
useEffect(() => {
if (visible && currentItem && episodes.length > 0) {
const currentIndex = episodes.findIndex((ep) => ep.Id === currentItem.Id);
if (visible && currentItem && filteredEpisodes.length > 0) {
const currentIndex = filteredEpisodes.findIndex(
(ep) => ep.Id === currentItem.Id,
);
if (currentIndex !== -1 && flatListRef.current) {
// Delay to ensure FlatList is rendered
setTimeout(() => {
@@ -48,7 +85,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}, 300);
}
}
}, [visible, currentItem, episodes]);
}, [visible, currentItem, filteredEpisodes]);
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
const isCurrentEpisode = item.Id === currentItem?.Id;
@@ -125,6 +162,15 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
</Text>
)}
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
{item.ParentIndexNumber !== undefined &&
item.IndexNumber !== undefined && (
<Text
style={{ color: "#a855f7", fontSize: 11, fontWeight: "600" }}
>
S{String(item.ParentIndexNumber).padStart(2, "0")}:E
{String(item.IndexNumber).padStart(2, "0")}
</Text>
)}
{item.ProductionYear && (
<Text style={{ color: "#666", fontSize: 11 }}>
{item.ProductionYear}
@@ -176,27 +222,66 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
{/* Header */}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
Episodes
</Text>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
</Pressable>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: seasons.length > 1 ? 12 : 0,
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
Episodes
</Text>
<Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' />
</Pressable>
</View>
{/* Season selector */}
{seasons.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8 }}
>
{seasons.map((season) => (
<Pressable
key={season}
onPress={() => setSelectedSeason(season)}
style={{
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor:
selectedSeason === season ? "#a855f7" : "#1a1a1a",
}}
>
<Text
style={{
color: "white",
fontSize: 14,
fontWeight: selectedSeason === season ? "600" : "400",
}}
>
Season {season}
</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
{/* Episode list */}
<FlatList
ref={flatListRef}
data={episodes}
data={filteredEpisodes}
renderItem={renderEpisode}
keyExtractor={(item) => item.Id || ""}
contentContainerStyle={{

View File

@@ -30,8 +30,6 @@ interface ChromecastSettingsMenuProps {
onSubtitleTrackChange: (track: SubtitleTrack | null) => void;
playbackSpeed: number;
onPlaybackSpeedChange: (speed: number) => void;
showTechnicalInfo: boolean;
onToggleTechnicalInfo: () => void;
}
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
@@ -51,8 +49,6 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
onSubtitleTrackChange,
playbackSpeed,
onPlaybackSpeedChange,
showTechnicalInfo,
onToggleTechnicalInfo,
}) => {
const insets = useSafeAreaInsets();
const [expandedSection, setExpandedSection] = useState<string | null>(null);
@@ -316,50 +312,6 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
))}
</View>
)}
{/* Technical Info Toggle */}
<Pressable
onPress={onToggleTechnicalInfo}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
>
<Ionicons name='information-circle' size={20} color='white' />
<Text
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
>
Show Technical Info
</Text>
</View>
<View
style={{
width: 50,
height: 30,
borderRadius: 15,
backgroundColor: showTechnicalInfo ? "#a855f7" : "#333",
justifyContent: "center",
alignItems: showTechnicalInfo ? "flex-end" : "flex-start",
padding: 2,
}}
>
<View
style={{
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: "white",
}}
/>
</View>
</Pressable>
</ScrollView>
</Pressable>
</Pressable>

View File

@@ -10,6 +10,7 @@ import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useCastDevice,
useCastSession,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
@@ -30,6 +31,7 @@ export const useCasting = (item: BaseItemDto | null) => {
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const mediaStatus = useMediaStatus();
const castSession = useCastSession();
// Local state
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
@@ -37,6 +39,7 @@ export const useCasting = (item: BaseItemDto | null) => {
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastReportedProgressRef = useRef(0);
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for
// Detect which protocol is active
const chromecastConnected = castDevice !== null;
@@ -85,44 +88,118 @@ export const useCasting = (item: BaseItemDto | null) => {
}
}, [mediaStatus, activeProtocol]);
// Chromecast: Sync volume from device
// Chromecast: Sync volume from device (both mediaStatus and CastSession)
useEffect(() => {
if (activeProtocol === "chromecast" && mediaStatus?.volume !== undefined) {
if (activeProtocol !== "chromecast") return;
// Sync from mediaStatus when available
if (mediaStatus?.volume !== undefined) {
setState((prev) => ({
...prev,
volume: mediaStatus.volume,
}));
}
}, [mediaStatus?.volume, activeProtocol]);
// Progress reporting to Jellyfin (optimized to skip redundant reports)
// Also poll CastSession for device volume to catch physical button changes
if (castSession) {
const volumeInterval = setInterval(() => {
castSession
.getVolume()
.then((deviceVolume) => {
if (deviceVolume !== undefined) {
setState((prev) => {
// Only update if significantly different to avoid jitter
if (Math.abs(prev.volume - deviceVolume) > 0.01) {
return { ...prev, volume: deviceVolume };
}
return prev;
});
}
})
.catch(() => {
// Ignore errors - device might be disconnected
});
}, 500); // Check every 500ms
return () => clearInterval(volumeInterval);
}
}, [mediaStatus?.volume, castSession, activeProtocol]);
// Progress reporting to Jellyfin (matches native player behavior)
useEffect(() => {
if (!isConnected || !item?.Id || !user?.Id || state.progress <= 0) return;
if (!isConnected || !item?.Id || !user?.Id || !api) return;
const playStateApi = getPlaystateApi(api);
// Report playback start when media begins (only once per item)
if (hasReportedStartRef.current !== item.Id && state.progress > 0) {
playStateApi
.reportPlaybackStart({
playbackStartInfo: {
ItemId: item.Id,
PositionTicks: Math.floor(state.progress * 10000),
PlayMethod:
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
VolumeLevel: Math.floor(state.volume * 100),
IsMuted: state.volume === 0,
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
},
})
.then(() => {
hasReportedStartRef.current = item.Id || null;
})
.catch((error) => {
console.error("[useCasting] Failed to report playback start:", error);
});
}
const reportProgress = () => {
const progressSeconds = Math.floor(state.progress / 1000);
// Don't report if no meaningful progress or if buffering
if (state.progress <= 0 || state.isBuffering) return;
// Skip if progress hasn't changed significantly (less than 5 seconds)
if (Math.abs(progressSeconds - lastReportedProgressRef.current) < 5) {
const progressMs = Math.floor(state.progress);
const progressTicks = progressMs * 10000; // Convert ms to ticks
const progressSeconds = Math.floor(progressMs / 1000);
// When paused, always report to keep server in sync
// When playing, skip if progress hasn't changed significantly (less than 3 seconds)
if (
state.isPlaying &&
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
) {
return;
}
lastReportedProgressRef.current = progressSeconds;
const playStateApi = api ? getPlaystateApi(api) : null;
playStateApi
?.reportPlaybackProgress({
.reportPlaybackProgress({
playbackProgressInfo: {
ItemId: item.Id,
PositionTicks: progressSeconds * 10000000,
PositionTicks: progressTicks,
IsPaused: !state.isPlaying,
PlayMethod:
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
// Add volume level for server tracking
VolumeLevel: Math.floor(state.volume * 100),
IsMuted: state.volume === 0,
// Include play session ID if available
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
},
})
.catch(console.error);
.catch((error) => {
console.error("[useCasting] Failed to report progress:", error);
});
};
const interval = setInterval(reportProgress, 10000);
// Report immediately on play/pause state change
reportProgress();
// Report every 5 seconds when paused, every 10 seconds when playing
const interval = setInterval(
reportProgress,
state.isPlaying ? 10000 : 5000,
);
return () => clearInterval(interval);
}, [
api,
@@ -130,17 +207,32 @@ export const useCasting = (item: BaseItemDto | null) => {
user?.Id,
state.progress,
state.isPlaying,
state.isBuffering, // Add buffering state to dependencies
state.volume,
isConnected,
activeProtocol,
mediaStatus?.mediaInfo?.contentId,
]);
// Play/Pause controls
const play = useCallback(async () => {
if (activeProtocol === "chromecast") {
await client?.play();
// Check if there's an active media session
if (!client || !mediaStatus?.mediaInfo) {
console.warn(
"[useCasting] Cannot play - no active media session. Media needs to be loaded first.",
);
return;
}
try {
await client.play();
} catch (error) {
console.error("[useCasting] Error playing:", error);
throw error;
}
}
// Future: Add play control for other protocols
}, [client, activeProtocol]);
}, [client, mediaStatus, activeProtocol]);
const pause = useCallback(async () => {
if (activeProtocol === "chromecast") {
@@ -160,12 +252,31 @@ export const useCasting = (item: BaseItemDto | null) => {
// Seek controls
const seek = useCallback(
async (positionMs: number) => {
// Validate position
if (positionMs < 0 || !Number.isFinite(positionMs)) {
console.error("[useCasting] Invalid seek position (ms):", positionMs);
return;
}
const positionSeconds = positionMs / 1000;
// Additional validation for Chromecast
if (activeProtocol === "chromecast") {
await client?.seek({ position: positionMs / 1000 });
if (positionSeconds > state.duration) {
console.warn(
"[useCasting] Seek position exceeds duration, clamping:",
positionSeconds,
"->",
state.duration,
);
await client?.seek({ position: state.duration });
return;
}
await client?.seek({ position: positionSeconds });
}
// Future: Add seek control for other protocols
},
[client, activeProtocol],
[client, activeProtocol, state.duration],
);
const skipForward = useCallback(
@@ -185,25 +296,33 @@ export const useCasting = (item: BaseItemDto | null) => {
);
// Stop and disconnect
const stop = useCallback(async () => {
if (activeProtocol === "chromecast") {
await client?.stop();
}
// Future: Add stop control for other protocols
const stop = useCallback(
async (onStopComplete?: () => void) => {
if (activeProtocol === "chromecast") {
await client?.stop();
}
// Future: Add stop control for other protocols
// Report stop to Jellyfin
if (api && item?.Id && user?.Id) {
const playStateApi = getPlaystateApi(api);
await playStateApi.reportPlaybackStopped({
playbackStopInfo: {
ItemId: item.Id,
PositionTicks: state.progress * 10000,
},
});
}
// Report stop to Jellyfin
if (api && item?.Id && user?.Id) {
const playStateApi = getPlaystateApi(api);
await playStateApi.reportPlaybackStopped({
playbackStopInfo: {
ItemId: item.Id,
PositionTicks: state.progress * 10000,
},
});
}
setState(DEFAULT_CAST_STATE);
}, [client, api, item?.Id, user?.Id, state.progress, activeProtocol]);
setState(DEFAULT_CAST_STATE);
// Call callback after stop completes (e.g., to navigate away)
if (onStopComplete) {
onStopComplete();
}
},
[client, api, item?.Id, user?.Id, state.progress, activeProtocol],
);
// Volume control (debounced to reduce API calls)
const setVolume = useCallback(
@@ -220,6 +339,8 @@ export const useCasting = (item: BaseItemDto | null) => {
volumeDebounceRef.current = setTimeout(async () => {
if (activeProtocol === "chromecast" && client && isConnected) {
// Use setStreamVolume for media stream volume (0.0 - 1.0)
// Physical volume buttons are handled automatically by the framework
await client.setStreamVolume(clampedVolume).catch((error) => {
console.log(
"[useCasting] Volume set failed (no session):",

View File

@@ -51,13 +51,28 @@
},
"casting_player": {
"buffering": "Buffering...",
"changing_audio": "Changing audio...",
"changing_subtitles": "Changing subtitles...",
"season_episode_format": "Season {{season}} • Episode {{episode}}",
"connection_quality": {
"excellent": "Excellent",
"good": "Good",
"fair": "Fair",
"poor": "Poor"
}
"poor": "Poor",
"disconnected": "Disconnected"
},
"error_title": "Chromecast Error",
"error_description": "Something went wrong with the cast session",
"retry": "Try Again",
"critical_error_title": "Multiple Errors Detected",
"critical_error_description": "Chromecast encountered multiple errors. Please disconnect and try casting again.",
"track_changed": "Track changed successfully",
"audio_track_changed": "Audio track changed",
"subtitle_track_changed": "Subtitle track changed",
"seeking": "Seeking...",
"seeking_error": "Failed to seek",
"load_failed": "Failed to load media",
"load_retry": "Retrying media load..."
},
"server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",

View File

@@ -13,6 +13,14 @@ export const chromecast: DeviceProfile = {
{
Type: "Audio",
Codec: "aac,mp3,flac,opus,vorbis",
// Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
Conditions: [
{
Condition: "LessThanEqual",
Property: "AudioChannels",
Value: "2",
},
],
},
],
ContainerProfiles: [],

View File

@@ -12,7 +12,14 @@ export const chromecasth265: DeviceProfile = {
},
{
Type: "Audio",
Codec: "aac,mp3,flac,opus,vorbis",
Codec: "aac,mp3,flac,opus,vorbis", // Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
Conditions: [
{
Condition: "LessThanEqual",
Property: "AudioChannels",
Value: "2",
},
],
},
],
ContainerProfiles: [],