mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-05 04:02:23 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user