Fix: Improve casting and segment skipping

Fixes several issues and improves the casting player experience.

- Adds the ability to disable segment skipping options based on plugin settings.
- Improves Chromecast integration by:
  - Adding PlaySessionId for better tracking.
  - Improves audio track selection
  - Uses mediaInfo builder for loading media.
  - Adds support for loading next/previous episodes
  - Translation support
- Updates progress reporting to Jellyfin to be more accurate and reliable.
- Fixes an error message in the direct player.
This commit is contained in:
Uruk
2026-02-08 15:01:02 +01:00
parent 761b464fb6
commit c243fbc0ba
24 changed files with 463 additions and 724 deletions

View File

@@ -96,6 +96,7 @@ export default function SegmentSkipPage() {
> >
<PlatformDropdown <PlatformDropdown
groups={skipIntroOptions} groups={skipIntroOptions}
disabled={pluginSettings?.skipIntro?.locked}
trigger={ trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'> <View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
@@ -119,6 +120,7 @@ export default function SegmentSkipPage() {
> >
<PlatformDropdown <PlatformDropdown
groups={skipOutroOptions} groups={skipOutroOptions}
disabled={pluginSettings?.skipOutro?.locked}
trigger={ trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'> <View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
@@ -142,6 +144,7 @@ export default function SegmentSkipPage() {
> >
<PlatformDropdown <PlatformDropdown
groups={skipRecapOptions} groups={skipRecapOptions}
disabled={pluginSettings?.skipRecap?.locked}
trigger={ trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'> <View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
@@ -165,6 +168,7 @@ export default function SegmentSkipPage() {
> >
<PlatformDropdown <PlatformDropdown
groups={skipCommercialOptions} groups={skipCommercialOptions}
disabled={pluginSettings?.skipCommercial?.locked}
trigger={ trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'> <View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
@@ -190,6 +194,7 @@ export default function SegmentSkipPage() {
> >
<PlatformDropdown <PlatformDropdown
groups={skipPreviewOptions} groups={skipPreviewOptions}
disabled={pluginSettings?.skipPreview?.locked}
trigger={ trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'> <View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>

View File

@@ -23,7 +23,6 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
import GoogleCast, { import GoogleCast, {
CastState, CastState,
MediaPlayerState, MediaPlayerState,
MediaStreamType,
useCastDevice, useCastDevice,
useCastState, useCastState,
useMediaStatus, useMediaStatus,
@@ -49,12 +48,10 @@ import {
calculateEndingTime, calculateEndingTime,
formatTime, formatTime,
getPosterUrl, getPosterUrl,
shouldShowNextEpisodeCountdown,
truncateTitle, truncateTitle,
} from "@/utils/casting/helpers"; } from "@/utils/casting/helpers";
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
import type { CastProtocol } from "@/utils/casting/types"; 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 { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast"; import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265"; import { chromecasth265 } from "@/utils/profiles/chromecasth265";
@@ -92,35 +89,25 @@ export default function CastingPlayerScreen() {
// Live progress tracking - update every second // Live progress tracking - update every second
const [liveProgress, setLiveProgress] = useState(0); const [liveProgress, setLiveProgress] = useState(0);
const lastSyncPositionRef = useRef(0);
const lastSyncTimestampRef = useRef(Date.now());
// Fetch full item data from Jellyfin by ID // Fetch full item data from Jellyfin by ID
const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null); const [fetchedItem, setFetchedItem] = useState<BaseItemDto | null>(null);
const [_isFetchingItem, setIsFetchingItem] = useState(false);
useEffect(() => { useEffect(() => {
const fetchItemData = async () => { const fetchItemData = async () => {
const itemId = mediaStatus?.mediaInfo?.contentId; const itemId = mediaStatus?.mediaInfo?.contentId;
if (!itemId || !api || !user?.Id) return; if (!itemId || !api || !user?.Id) return;
setIsFetchingItem(true);
try { try {
const res = await getUserLibraryApi(api).getItem({ const res = await getUserLibraryApi(api).getItem({
itemId, itemId,
userId: user.Id, userId: user.Id,
}); });
console.log("[Casting Player] Fetched full item from API:", {
Type: res.data.Type,
Name: res.data.Name,
SeriesName: res.data.SeriesName,
SeasonId: res.data.SeasonId,
ParentIndexNumber: res.data.ParentIndexNumber,
IndexNumber: res.data.IndexNumber,
});
setFetchedItem(res.data); setFetchedItem(res.data);
} catch (error) { } catch (error) {
console.error("[Casting Player] Failed to fetch item:", error); console.error("[Casting Player] Failed to fetch item:", error);
} finally {
setIsFetchingItem(false);
} }
}; };
@@ -128,18 +115,21 @@ export default function CastingPlayerScreen() {
}, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]); }, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]);
useEffect(() => { useEffect(() => {
// Initialize with actual position // Sync refs whenever mediaStatus provides a new position
if (mediaStatus?.streamPosition) { if (mediaStatus?.streamPosition !== undefined) {
lastSyncPositionRef.current = mediaStatus.streamPosition;
lastSyncTimestampRef.current = Date.now();
setLiveProgress(mediaStatus.streamPosition); setLiveProgress(mediaStatus.streamPosition);
} }
// Update every second when playing // Update every second when playing, deriving from last sync point
const interval = setInterval(() => { const interval = setInterval(() => {
if ( if (
mediaStatus?.playerState === MediaPlayerState.PLAYING && mediaStatus?.playerState === MediaPlayerState.PLAYING &&
mediaStatus?.streamPosition !== undefined mediaStatus?.streamPosition !== undefined
) { ) {
setLiveProgress((prev) => prev + 1); const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000;
setLiveProgress(lastSyncPositionRef.current + elapsed);
} else if (mediaStatus?.streamPosition !== undefined) { } else if (mediaStatus?.streamPosition !== undefined) {
// Sync with actual position when paused/buffering // Sync with actual position when paused/buffering
setLiveProgress(mediaStatus.streamPosition); setLiveProgress(mediaStatus.streamPosition);
@@ -153,7 +143,6 @@ export default function CastingPlayerScreen() {
const currentItem = useMemo(() => { const currentItem = useMemo(() => {
// Priority 1: Use fetched item from API (most reliable) // Priority 1: Use fetched item from API (most reliable)
if (fetchedItem) { if (fetchedItem) {
console.log("[Casting Player] Using fetched item from API");
return fetchedItem; return fetchedItem;
} }
@@ -161,19 +150,12 @@ export default function CastingPlayerScreen() {
const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null; const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null;
if (customData?.Type && customData.Type !== "Movie") { if (customData?.Type && customData.Type !== "Movie") {
// Only use customData if it has a real Type (not default fallback) // Only use customData if it has a real Type (not default fallback)
console.log("[Casting Player] Using customData item:", {
Type: customData.Type,
Name: customData.Name,
});
return customData; return customData;
} }
// Priority 3: Create minimal fallback while loading // Priority 3: Create minimal fallback while loading
if (mediaStatus?.mediaInfo) { if (mediaStatus?.mediaInfo) {
const { contentId, metadata } = mediaStatus.mediaInfo; const { contentId, metadata } = mediaStatus.mediaInfo;
console.log(
"[Casting Player] Using minimal fallback item (still loading)",
);
return { return {
Id: contentId, Id: contentId,
Name: metadata?.title || "Unknown", Name: metadata?.title || "Unknown",
@@ -227,7 +209,7 @@ export default function CastingPlayerScreen() {
togglePlayPause: async () => {}, togglePlayPause: async () => {},
skipForward: async () => {}, skipForward: async () => {},
skipBackward: async () => {}, skipBackward: async () => {},
setVolume: async () => {}, setVolume: () => {},
volume: 1, volume: 1,
remoteMediaClient: null, remoteMediaClient: null,
}; };
@@ -264,10 +246,6 @@ export default function CastingPlayerScreen() {
try { try {
// Save current playback position // Save current playback position
const currentPosition = mediaStatus?.streamPosition ?? 0; const currentPosition = mediaStatus?.streamPosition ?? 0;
console.log(
"[Casting Player] Reloading stream at position:",
currentPosition,
);
// Get new stream URL with updated settings // Get new stream URL with updated settings
const enableH265 = settings.enableH265ForChromecast; const enableH265 = settings.enableH265ForChromecast;
@@ -288,74 +266,15 @@ export default function CastingPlayerScreen() {
return; return;
} }
console.log("[Casting Player] Reloading with new URL:", data.url);
// Reload media with new URL // Reload media with new URL
await remoteMediaClient.loadMedia({ await remoteMediaClient.loadMedia({
mediaInfo: { mediaInfo: buildCastMediaInfo({
contentId: currentItem.Id, item: currentItem,
contentUrl: data.url, streamUrl: data.url,
contentType: "video/mp4", api,
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 startTime: currentPosition, // Resume at same position
}); });
console.log("[Casting Player] Stream reloaded successfully");
} catch (error) { } catch (error) {
console.error("[Casting Player] Failed to reload stream:", error); console.error("[Casting Player] Failed to reload stream:", error);
} }
@@ -371,6 +290,47 @@ export default function CastingPlayerScreen() {
], ],
); );
// Load a different episode on the Chromecast
const loadEpisode = useCallback(
async (episode: BaseItemDto) => {
if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return;
try {
const enableH265 = settings.enableH265ForChromecast;
const data = await getStreamUrl({
api,
item: episode,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: episode.UserData?.PlaybackPositionTicks ?? 0,
userId: user.Id,
});
if (!data?.url) {
console.error(
"[Casting Player] Failed to get stream URL for episode",
);
return;
}
await remoteMediaClient.loadMedia({
mediaInfo: buildCastMediaInfo({
item: episode,
streamUrl: data.url,
api,
}),
startTime: (episode.UserData?.PlaybackPositionTicks ?? 0) / 10000000,
});
// Reset track selections for new episode
setSelectedAudioTrackIndex(null);
setSelectedSubtitleTrackIndex(null);
} catch (error) {
console.error("[Casting Player] Failed to load episode:", error);
}
},
[api, user?.Id, remoteMediaClient, settings.enableH265ForChromecast],
);
// Fetch season data for season poster // Fetch season data for season poster
useEffect(() => { useEffect(() => {
if ( if (
@@ -383,20 +343,11 @@ export default function CastingPlayerScreen() {
const fetchSeasonData = async () => { const fetchSeasonData = async () => {
try { try {
console.log(
`[Casting Player] Fetching season data for SeasonId: ${currentItem.SeasonId}`,
);
const userLibraryApi = getUserLibraryApi(api); const userLibraryApi = getUserLibraryApi(api);
const response = await userLibraryApi.getItem({ const response = await userLibraryApi.getItem({
itemId: currentItem.SeasonId!, itemId: currentItem.SeasonId!,
userId: user.Id!, userId: user.Id!,
}); });
console.log("[Casting Player] Season data fetched:", {
Id: response.data.Id,
Name: response.data.Name,
ImageTags: response.data.ImageTags,
ParentPrimaryImageItemId: response.data.ParentPrimaryImageItemId,
});
setSeasonData(response.data); setSeasonData(response.data);
} catch (error) { } catch (error) {
console.error("[Casting Player] Failed to fetch season data:", error); console.error("[Casting Player] Failed to fetch season data:", error);
@@ -485,12 +436,16 @@ export default function CastingPlayerScreen() {
return variants; return variants;
}, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]); }, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]);
// Track whether user has manually selected an audio track
const [userSelectedAudio, setUserSelectedAudio] = useState(false);
// Auto-select stereo audio track for better Chromecast compatibility // Auto-select stereo audio track for better Chromecast compatibility
// Note: This only updates the UI state. The actual audio track change requires // Note: This only updates the UI state. The actual audio track change requires
// regenerating the stream URL, which would be disruptive on initial load. // regenerating the stream URL, which would be disruptive on initial load.
// The user can manually switch audio tracks if needed. // The user can manually switch audio tracks if needed.
useEffect(() => { useEffect(() => {
if (!remoteMediaClient || !mediaStatus?.mediaInfo) return; if (!remoteMediaClient || !mediaStatus?.mediaInfo || userSelectedAudio)
return;
const currentTrack = availableAudioTracks.find( const currentTrack = availableAudioTracks.find(
(t) => t.index === selectedAudioTrackIndex, (t) => t.index === selectedAudioTrackIndex,
@@ -500,12 +455,6 @@ export default function CastingPlayerScreen() {
if (currentTrack && (currentTrack.channels || 0) > 2) { if (currentTrack && (currentTrack.channels || 0) > 2) {
const stereoTrack = availableAudioTracks.find((t) => t.channels === 2); const stereoTrack = availableAudioTracks.find((t) => t.channels === 2);
if (stereoTrack && stereoTrack.index !== selectedAudioTrackIndex) { if (stereoTrack && stereoTrack.index !== selectedAudioTrackIndex) {
console.log(
"[Audio] Note: 5.1 audio detected. Stereo available:",
currentTrack.displayTitle,
"->",
stereoTrack.displayTitle,
);
// Auto-select stereo in UI (user can manually trigger reload) // Auto-select stereo in UI (user can manually trigger reload)
setSelectedAudioTrackIndex(stereoTrack.index); setSelectedAudioTrackIndex(stereoTrack.index);
} }
@@ -515,6 +464,7 @@ export default function CastingPlayerScreen() {
availableAudioTracks, availableAudioTracks,
remoteMediaClient, remoteMediaClient,
selectedAudioTrackIndex, selectedAudioTrackIndex,
userSelectedAudio,
]); ]);
// Fetch episodes for TV shows // Fetch episodes for TV shows
@@ -562,8 +512,7 @@ export default function CastingPlayerScreen() {
useEffect(() => { useEffect(() => {
if (mediaStatus?.currentItemId && !currentItem) { if (mediaStatus?.currentItemId && !currentItem) {
// New media started casting while we're not on the player // New media started casting while we're not on the player
console.log("[Casting Player] Auto-navigating to player for new cast"); router.replace("/casting-player" as "/casting-player");
router.replace("/casting-player" as any);
} }
}, [mediaStatus?.currentItemId, currentItem, router]); }, [mediaStatus?.currentItemId, currentItem, router]);
@@ -627,23 +576,12 @@ export default function CastingPlayerScreen() {
// Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags // Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags
const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id; const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id;
const seasonImageTag = seasonData.ImageTags?.Primary; const seasonImageTag = seasonData.ImageTags?.Primary;
console.log(
`[Casting Player] Using season poster for ${seasonData.Name}`,
{ imageItemId, seasonImageTag },
);
return seasonImageTag return seasonImageTag
? `${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&tag=${seasonImageTag}`
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`; : `${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 // Fallback to item poster for non-episodes or if season data not loaded
console.log(
`[Casting Player] Using fallback poster for ${currentItem.Name}`,
{
Type: currentItem.Type,
hasSeasonData: !!seasonData?.Id,
},
);
return getPosterUrl( return getPosterUrl(
api.basePath, api.basePath,
currentItem.Id, currentItem.Id,
@@ -662,12 +600,6 @@ export default function CastingPlayerScreen() {
const protocolColor = "#a855f7"; // Streamyfin purple const protocolColor = "#a855f7"; // Streamyfin purple
const _showNextEpisode = useMemo(() => {
if (currentItem?.Type !== "Episode" || !nextEpisode) return false;
const remaining = duration - progress;
return shouldShowNextEpisodeCountdown(remaining, true, 30);
}, [currentItem?.Type, nextEpisode, duration, progress]);
// Redirect if not connected - check CastState like old implementation // Redirect if not connected - check CastState like old implementation
useEffect(() => { useEffect(() => {
// Redirect immediately when disconnected or no devices // Redirect immediately when disconnected or no devices
@@ -686,7 +618,7 @@ export default function CastingPlayerScreen() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [castState]); }, [castState, router]);
// Also redirect if mediaStatus disappears (media ended or stopped) // Also redirect if mediaStatus disappears (media ended or stopped)
useEffect(() => { useEffect(() => {
@@ -701,7 +633,7 @@ export default function CastingPlayerScreen() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [castState, mediaStatus]); }, [castState, mediaStatus, router]);
// Show loading while connecting // Show loading while connecting
if (castState === CastState.CONNECTING) { if (castState === CastState.CONNECTING) {
@@ -716,7 +648,7 @@ export default function CastingPlayerScreen() {
> >
<ActivityIndicator size='large' color='#fff' /> <ActivityIndicator size='large' color='#fff' />
<Text style={{ color: "#fff", marginTop: 16 }}> <Text style={{ color: "#fff", marginTop: 16 }}>
Connecting to Chromecast... {t("casting_player.connecting")}
</Text> </Text>
</View> </View>
); );
@@ -795,7 +727,7 @@ export default function CastingPlayerScreen() {
fontWeight: "500", fontWeight: "500",
}} }}
> >
{currentDevice || "Unknown Device"} {currentDevice || t("casting_player.unknown_device")}
</Text> </Text>
</Pressable> </Pressable>
@@ -831,7 +763,10 @@ export default function CastingPlayerScreen() {
marginBottom: 6, marginBottom: 6,
}} }}
> >
{truncateTitle(currentItem.Name || "Unknown", 50)} {truncateTitle(
currentItem.Name || t("casting_player.unknown"),
50,
)}
</Text> </Text>
{/* Grey episode/season info */} {/* Grey episode/season info */}
@@ -1028,13 +963,12 @@ export default function CastingPlayerScreen() {
const currentIndex = episodes.findIndex( const currentIndex = episodes.findIndex(
(ep) => ep.Id === currentItem.Id, (ep) => ep.Id === currentItem.Id,
); );
if (currentIndex > 0 && remoteMediaClient) { if (currentIndex > 0) {
const previousEp = episodes[currentIndex - 1]; await loadEpisode(episodes[currentIndex - 1]);
console.log("Previous episode:", previousEp.Name);
} }
}} }}
disabled={ disabled={
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0 episodes.findIndex((ep) => ep.Id === currentItem.Id) <= 0
} }
style={{ style={{
flex: 1, flex: 1,
@@ -1045,7 +979,7 @@ export default function CastingPlayerScreen() {
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
opacity: opacity:
episodes.findIndex((ep) => ep.Id === currentItem.Id) === 0 episodes.findIndex((ep) => ep.Id === currentItem.Id) <= 0
? 0.4 ? 0.4
: 1, : 1,
}} }}
@@ -1056,8 +990,8 @@ export default function CastingPlayerScreen() {
{/* Next episode button */} {/* Next episode button */}
<Pressable <Pressable
onPress={async () => { onPress={async () => {
if (nextEpisode && remoteMediaClient) { if (nextEpisode) {
console.log("Next episode:", nextEpisode.Name); await loadEpisode(nextEpisode);
} }
}} }}
disabled={!nextEpisode} disabled={!nextEpisode}
@@ -1331,8 +1265,9 @@ export default function CastingPlayerScreen() {
{formatTime(progress * 1000)} {formatTime(progress * 1000)}
</Text> </Text>
<Text style={{ color: "#999", fontSize: 13 }}> <Text style={{ color: "#999", fontSize: 13 }}>
Ending at{" "} {t("casting_player.ending_at", {
{calculateEndingTime(progress * 1000, duration * 1000)} time: calculateEndingTime(progress * 1000, duration * 1000),
})}
</Text> </Text>
<Text style={{ color: "#999", fontSize: 13 }}> <Text style={{ color: "#999", fontSize: 13 }}>
{formatTime(duration * 1000)} {formatTime(duration * 1000)}
@@ -1432,10 +1367,7 @@ export default function CastingPlayerScreen() {
onClose={() => setShowDeviceSheet(false)} onClose={() => setShowDeviceSheet(false)}
device={ device={
currentDevice && protocol === "chromecast" && castDevice currentDevice && protocol === "chromecast" && castDevice
? ({ ? { friendlyName: currentDevice }
deviceId: castDevice.deviceId,
friendlyName: currentDevice,
} as any)
: null : null
} }
onDisconnect={async () => { onDisconnect={async () => {
@@ -1461,7 +1393,11 @@ export default function CastingPlayerScreen() {
}} }}
volume={volume} volume={volume}
onVolumeChange={async (vol) => { onVolumeChange={async (vol) => {
setVolume(vol); try {
await setVolume(vol);
} catch (error) {
console.error("[Casting Player] Failed to set volume:", error);
}
}} }}
/> />
@@ -1471,10 +1407,9 @@ export default function CastingPlayerScreen() {
currentItem={currentItem} currentItem={currentItem}
episodes={episodes} episodes={episodes}
api={api} api={api}
onSelectEpisode={(episode) => { onSelectEpisode={async (episode) => {
// TODO: Load new episode - requires casting new media
console.log("Selected episode:", episode.Name);
setShowEpisodeList(false); setShowEpisodeList(false);
await loadEpisode(episode);
}} }}
/> />
@@ -1489,8 +1424,6 @@ export default function CastingPlayerScreen() {
})} })}
selectedMediaSource={availableMediaSources[0] || null} selectedMediaSource={availableMediaSources[0] || null}
onMediaSourceChange={(source) => { onMediaSourceChange={(source) => {
// Reload stream with new bitrate
console.log("Changed media source:", source);
reloadWithSettings({ bitrateValue: source.bitrate }); reloadWithSettings({ bitrateValue: source.bitrate });
}} }}
audioTracks={availableAudioTracks} audioTracks={availableAudioTracks}
@@ -1502,6 +1435,7 @@ export default function CastingPlayerScreen() {
: availableAudioTracks[0] || null : availableAudioTracks[0] || null
} }
onAudioTrackChange={(track) => { onAudioTrackChange={(track) => {
setUserSelectedAudio(true);
setSelectedAudioTrackIndex(track.index); setSelectedAudioTrackIndex(track.index);
// Reload stream with new audio track // Reload stream with new audio track
reloadWithSettings({ audioIndex: track.index }); reloadWithSettings({ audioIndex: track.index });

View File

@@ -939,7 +939,7 @@ export default function page() {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
t("player.error"), t("player.error"),
t("player.an_error_occured_while_playing_the_video"), t("player.an_error_occurred_while_playing_the_video"),
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}

View File

@@ -41,6 +41,8 @@ export function Chromecast({
const isConnected = castState === CastState.CONNECTED; const isConnected = castState === CastState.CONNECTED;
const lastReportedProgressRef = useRef(0); const lastReportedProgressRef = useRef(0);
const playSessionIdRef = useRef<string | null>(null);
const lastContentIdRef = useRef<string | null>(null);
const discoveryAttempts = useRef(0); const discoveryAttempts = useRef(0);
const maxDiscoveryAttempts = 3; const maxDiscoveryAttempts = 3;
const hasLoggedDevices = useRef(false); const hasLoggedDevices = useRef(false);
@@ -121,6 +123,13 @@ export function Chromecast({
} }
const contentId = mediaStatus.mediaInfo.contentId; const contentId = mediaStatus.mediaInfo.contentId;
// Generate a new PlaySessionId when the content changes
if (contentId !== lastContentIdRef.current) {
playSessionIdRef.current = `${contentId}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
lastContentIdRef.current = contentId;
}
const positionTicks = Math.floor(streamPosition * 10000000); const positionTicks = Math.floor(streamPosition * 10000000);
const isPaused = mediaStatus.playerState === "paused"; const isPaused = mediaStatus.playerState === "paused";
const streamUrl = mediaStatus.mediaInfo.contentUrl || ""; const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
@@ -131,7 +140,7 @@ export function Chromecast({
PositionTicks: positionTicks, PositionTicks: positionTicks,
IsPaused: isPaused, IsPaused: isPaused,
PlayMethod: isTranscoding ? "Transcode" : "DirectStream", PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
PlaySessionId: contentId, PlaySessionId: playSessionIdRef.current || contentId,
}; };
getPlaystateApi(api) getPlaystateApi(api)

View File

@@ -47,6 +47,7 @@ interface PlatformDropdownProps {
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void; onOptionSelect?: (value?: any) => void;
disabled?: boolean;
expoUIConfig?: { expoUIConfig?: {
hostStyle?: any; hostStyle?: any;
}; };
@@ -197,6 +198,7 @@ const PlatformDropdownComponent = ({
onOpenChange: controlledOnOpenChange, onOpenChange: controlledOnOpenChange,
onOptionSelect, onOptionSelect,
expoUIConfig, expoUIConfig,
disabled,
bottomSheetConfig, bottomSheetConfig,
}: PlatformDropdownProps) => { }: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal(); const { showModal, hideModal, isVisible } = useGlobalModal();
@@ -231,6 +233,13 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]); }, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
if (disabled) {
return (
<View style={{ opacity: 0.5 }} pointerEvents='none'>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
);
}
return ( return (
<Host style={expoUIConfig?.hostStyle}> <Host style={expoUIConfig?.hostStyle}>
<ContextMenu> <ContextMenu>
@@ -353,8 +362,14 @@ const PlatformDropdownComponent = ({
}; };
return ( return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}> <TouchableOpacity
{trigger || <Text className='text-white'>Open Menu</Text>} onPress={handlePress}
activeOpacity={0.7}
disabled={disabled}
>
<View style={disabled ? { opacity: 0.5 } : undefined}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</View>
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -9,7 +9,6 @@ import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
MediaPlayerState, MediaPlayerState,
MediaStreamType,
PlayServicesState, PlayServicesState,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
@@ -33,8 +32,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast"; import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265"; import { chromecasth265 } from "@/utils/profiles/chromecasth265";
@@ -112,7 +110,11 @@ export const PlayButton: React.FC<Props> = ({
return; return;
} }
const options = ["Chromecast", "Device", "Cancel"]; const options = [
t("casting_player.chromecast"),
t("casting_player.device"),
t("casting_player.cancel"),
];
const cancelButtonIndex = 2; const cancelButtonIndex = 2;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
@@ -181,17 +183,6 @@ export const PlayButton: React.FC<Props> = ({
subtitleStreamIndex: selectedOptions.subtitleIndex, subtitleStreamIndex: selectedOptions.subtitleIndex,
}); });
console.log("URL: ", data?.url, enableH265);
console.log("[PlayButton] Item before casting:", {
Type: item.Type,
Id: item.Id,
Name: item.Name,
ParentIndexNumber: item.ParentIndexNumber,
IndexNumber: item.IndexNumber,
SeasonId: item.SeasonId,
SeriesId: item.SeriesId,
});
if (!data?.url) { if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data); console.warn("No URL returned from getStreamUrl", data);
Alert.alert( Alert.alert(
@@ -201,80 +192,16 @@ export const PlayButton: React.FC<Props> = ({
return; return;
} }
// Calculate start time in seconds from playback position
const startTimeSeconds = const startTimeSeconds =
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000; (item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
// Calculate stream duration in seconds from runtime
const streamDurationSeconds = item.RunTimeTicks
? item.RunTimeTicks / 10000000
: undefined;
console.log("[PlayButton] Loading media with customData:", {
hasCustomData: !!item,
customDataType: item.Type,
});
client client
.loadMedia({ .loadMedia({
mediaInfo: { mediaInfo: buildCastMediaInfo({
contentId: item.Id, item,
contentUrl: data?.url, streamUrl: data.url,
contentType: "video/mp4", api,
streamType: MediaStreamType.BUFFERED, }),
streamDuration: streamDurationSeconds,
customData: item,
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: startTimeSeconds, startTime: startTimeSeconds,
}) })
.then(() => { .then(() => {
@@ -285,7 +212,7 @@ export const PlayButton: React.FC<Props> = ({
router.push("/casting-player"); router.push("/casting-player");
}); });
} catch (e) { } catch (e) {
console.log(e); console.error("[PlayButton] Cast error:", e);
} }
} }
}); });

View File

@@ -110,9 +110,10 @@ export const CastingMiniPlayer: React.FC = () => {
if ( if (
currentItem.Type === "Episode" && currentItem.Type === "Episode" &&
currentItem.SeriesId && currentItem.SeriesId &&
currentItem.ParentIndexNumber currentItem.ParentIndexNumber !== undefined &&
currentItem.SeasonId
) { ) {
// Build season poster URL using SeriesId and season number // Build season poster URL using SeriesId and SeasonId as tag
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96&tag=${currentItem.SeasonId}`; return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96&tag=${currentItem.SeasonId}`;
} }
@@ -146,12 +147,15 @@ export const CastingMiniPlayer: React.FC = () => {
router.push("/casting-player"); router.push("/casting-player");
}; };
const handleTogglePlayPause = (e: any) => { const handleTogglePlayPause = () => {
e.stopPropagation();
if (isPlaying) { if (isPlaying) {
remoteMediaClient?.pause(); remoteMediaClient?.pause()?.catch((error: unknown) => {
console.error("[CastingMiniPlayer] Pause error:", error);
});
} else { } else {
remoteMediaClient?.play(); remoteMediaClient?.play()?.catch((error: unknown) => {
console.error("[CastingMiniPlayer] Play error:", error);
});
} }
}; };

View File

@@ -6,6 +6,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, View } from "react-native"; import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -24,6 +25,7 @@ export const ChromecastConnectionMenu: React.FC<
ChromecastConnectionMenuProps ChromecastConnectionMenuProps
> = ({ visible, onClose, onDisconnect }) => { > = ({ visible, onClose, onDisconnect }) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
const castDevice = useCastDevice(); const castDevice = useCastDevice();
const castSession = useCastSession(); const castSession = useCastSession();
@@ -191,10 +193,10 @@ export const ChromecastConnectionMenu: React.FC<
<Text <Text
style={{ color: "white", fontSize: 16, fontWeight: "600" }} style={{ color: "white", fontSize: 16, fontWeight: "600" }}
> >
{castDevice?.friendlyName || "Chromecast"} {castDevice?.friendlyName || t("casting_player.chromecast")}
</Text> </Text>
<Text style={{ color: protocolColor, fontSize: 12 }}> <Text style={{ color: protocolColor, fontSize: 12 }}>
Connected {t("casting_player.connected")}
</Text> </Text>
</View> </View>
</View> </View>
@@ -213,9 +215,11 @@ export const ChromecastConnectionMenu: React.FC<
marginBottom: 12, marginBottom: 12,
}} }}
> >
<Text style={{ color: "#999", fontSize: 12 }}>Volume</Text> <Text style={{ color: "#999", fontSize: 12 }}>
{t("casting_player.volume")}
</Text>
<Text style={{ color: "white", fontSize: 14 }}> <Text style={{ color: "white", fontSize: 14 }}>
{isMuted ? "Muted" : `${displayVolume}%`} {isMuted ? t("casting_player.muted") : `${displayVolume}%`}
</Text> </Text>
</View> </View>
<View <View
@@ -255,7 +259,15 @@ export const ChromecastConnectionMenu: React.FC<
handleVolumeChange(value); handleVolumeChange(value);
if (isMuted) { if (isMuted) {
setIsMuted(false); setIsMuted(false);
castSession?.setMute(false); try {
castSession?.setMute(false);
} catch (error: unknown) {
console.error(
"[ChromecastConnectionMenu] Failed to unmute:",
error,
);
setIsMuted(true); // Rollback on failure
}
} }
}} }}
onSlidingComplete={handleVolumeComplete} onSlidingComplete={handleVolumeComplete}
@@ -288,7 +300,7 @@ export const ChromecastConnectionMenu: React.FC<
<Text <Text
style={{ color: "white", fontSize: 14, fontWeight: "500" }} style={{ color: "white", fontSize: 14, fontWeight: "500" }}
> >
Disconnect {t("casting_player.disconnect")}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>

View File

@@ -5,10 +5,11 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, View } from "react-native"; import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { type Device, useCastSession } from "react-native-google-cast"; import { useCastSession } from "react-native-google-cast";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -16,7 +17,7 @@ import { Text } from "@/components/common/Text";
interface ChromecastDeviceSheetProps { interface ChromecastDeviceSheetProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
device: Device | null; device: { friendlyName?: string } | null;
onDisconnect: () => Promise<void>; onDisconnect: () => Promise<void>;
volume?: number; volume?: number;
onVolumeChange?: (volume: number) => Promise<void>; onVolumeChange?: (volume: number) => Promise<void>;
@@ -31,6 +32,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
onVolumeChange, onVolumeChange,
}) => { }) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [isDisconnecting, setIsDisconnecting] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false);
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100)); const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
const volumeValue = useSharedValue(volume * 100); const volumeValue = useSharedValue(volume * 100);
@@ -76,16 +78,14 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
// Check mute state // Check mute state
const muteState = await castSession.isMute(); const muteState = await castSession.isMute();
if (muteState !== isMuted) { setIsMuted(muteState);
setIsMuted(muteState);
}
} catch { } catch {
// Ignore errors - device might be disconnected // Ignore errors - device might be disconnected
} }
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [visible, castSession, displayVolume, volumeValue, isMuted]); }, [visible, castSession, volumeValue]);
const handleDisconnect = async () => { const handleDisconnect = async () => {
setIsDisconnecting(true); setIsDisconnecting(true);
@@ -107,7 +107,6 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
// This works even when no media is playing, unlike setStreamVolume // This works even when no media is playing, unlike setStreamVolume
if (castSession) { if (castSession) {
await castSession.setVolume(newVolume); await castSession.setVolume(newVolume);
console.log("[Volume] Set device volume via CastSession:", newVolume);
} else if (onVolumeChange) { } else if (onVolumeChange) {
// Fallback to prop method if session not available // Fallback to prop method if session not available
await onVolumeChange(newVolume); await onVolumeChange(newVolume);
@@ -153,6 +152,15 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
} }
}, [castSession, isMuted]); }, [castSession, isMuted]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (volumeDebounceRef.current) {
clearTimeout(volumeDebounceRef.current);
}
};
}, []);
return ( return (
<Modal <Modal
visible={visible} visible={visible}
@@ -196,7 +204,7 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<Text <Text
style={{ color: "white", fontSize: 18, fontWeight: "600" }} style={{ color: "white", fontSize: 18, fontWeight: "600" }}
> >
Chromecast {t("casting_player.chromecast")}
</Text> </Text>
</View> </View>
<Pressable onPress={onClose} style={{ padding: 8 }}> <Pressable onPress={onClose} style={{ padding: 8 }}>
@@ -208,12 +216,12 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<View style={{ padding: 16 }}> <View style={{ padding: 16 }}>
<View style={{ marginBottom: 20 }}> <View style={{ marginBottom: 20 }}>
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}> <Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
Device Name {t("casting_player.device_name")}
</Text> </Text>
<Text <Text
style={{ color: "white", fontSize: 16, fontWeight: "500" }} style={{ color: "white", fontSize: 16, fontWeight: "500" }}
> >
{device?.friendlyName || "Unknown Device"} {device?.friendlyName || t("casting_player.unknown_device")}
</Text> </Text>
</View> </View>
{/* Volume control */} {/* Volume control */}
@@ -226,9 +234,11 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
marginBottom: 12, marginBottom: 12,
}} }}
> >
<Text style={{ color: "#999", fontSize: 12 }}>Volume</Text> <Text style={{ color: "#999", fontSize: 12 }}>
{t("casting_player.volume")}
</Text>
<Text style={{ color: "white", fontSize: 14 }}> <Text style={{ color: "white", fontSize: 14 }}>
{isMuted ? "Muted" : `${displayVolume}%`} {isMuted ? t("casting_player.muted") : `${displayVolume}%`}
</Text> </Text>
</View> </View>
<View <View
@@ -317,7 +327,9 @@ export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
<Text <Text
style={{ color: "white", fontSize: 16, fontWeight: "600" }} style={{ color: "white", fontSize: 16, fontWeight: "600" }}
> >
{isDisconnecting ? "Disconnecting..." : "Stop Casting"} {isDisconnecting
? t("casting_player.disconnecting")
: t("casting_player.stop_casting")}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>

View File

@@ -8,6 +8,7 @@ import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native"; import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -32,6 +33,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
api, api,
}) => { }) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null); const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
@@ -76,13 +78,14 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
); );
if (currentIndex !== -1 && flatListRef.current) { if (currentIndex !== -1 && flatListRef.current) {
// Delay to ensure FlatList is rendered // Delay to ensure FlatList is rendered
setTimeout(() => { const timeoutId = setTimeout(() => {
flatListRef.current?.scrollToIndex({ flatListRef.current?.scrollToIndex({
index: currentIndex, index: currentIndex,
animated: true, animated: true,
viewPosition: 0.5, // Center the item viewPosition: 0.5, // Center the item
}); });
}, 300); }, 300);
return () => clearTimeout(timeoutId);
} }
} }
}, [visible, currentItem, filteredEpisodes]); }, [visible, currentItem, filteredEpisodes]);
@@ -147,7 +150,8 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}} }}
numberOfLines={1} numberOfLines={1}
> >
{item.IndexNumber}. {truncateTitle(item.Name || "Unknown", 30)} {item.IndexNumber}.{" "}
{truncateTitle(item.Name || t("casting_player.unknown"), 30)}
</Text> </Text>
{item.Overview && ( {item.Overview && (
<Text <Text
@@ -178,7 +182,8 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
)} )}
{item.RunTimeTicks && ( {item.RunTimeTicks && (
<Text style={{ color: "#666", fontSize: 11 }}> <Text style={{ color: "#666", fontSize: 11 }}>
{Math.round(item.RunTimeTicks / 600000000)} min {Math.round(item.RunTimeTicks / 600000000)}{" "}
{t("casting_player.minutes_short")}
</Text> </Text>
)} )}
</View> </View>
@@ -237,7 +242,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
}} }}
> >
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}> <Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
Episodes {t("casting_player.episodes")}
</Text> </Text>
<Pressable onPress={onClose} style={{ padding: 8 }}> <Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' /> <Ionicons name='close' size={24} color='white' />
@@ -270,7 +275,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
fontWeight: selectedSeason === season ? "600" : "400", fontWeight: selectedSeason === season ? "600" : "400",
}} }}
> >
Season {season} {t("casting_player.season", { number: season })}
</Text> </Text>
</Pressable> </Pressable>
))} ))}
@@ -283,7 +288,7 @@ export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
ref={flatListRef} ref={flatListRef}
data={filteredEpisodes} data={filteredEpisodes}
renderItem={renderEpisode} renderItem={renderEpisode}
keyExtractor={(item) => item.Id || ""} keyExtractor={(item, index) => item.Id || `episode-${index}`}
contentContainerStyle={{ contentContainerStyle={{
padding: 16, padding: 16,
paddingBottom: insets.bottom + 16, paddingBottom: insets.bottom + 16,

View File

@@ -6,6 +6,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, ScrollView, View } from "react-native"; import { Modal, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -51,6 +52,7 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
onPlaybackSpeedChange, onPlaybackSpeedChange,
}) => { }) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [expandedSection, setExpandedSection] = useState<string | null>(null); const [expandedSection, setExpandedSection] = useState<string | null>(null);
const toggleSection = (section: string) => { const toggleSection = (section: string) => {
@@ -124,7 +126,7 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
}} }}
> >
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}> <Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
Playback Settings {t("casting_player.playback_settings")}
</Text> </Text>
<Pressable onPress={onClose} style={{ padding: 8 }}> <Pressable onPress={onClose} style={{ padding: 8 }}>
<Ionicons name='close' size={24} color='white' /> <Ionicons name='close' size={24} color='white' />
@@ -132,9 +134,14 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
</View> </View>
<ScrollView> <ScrollView>
{/* Quality/Media Source */} {/* Quality/Media Source - only show when sources available */}
{renderSectionHeader("Quality", "film-outline", "quality")} {mediaSources.length > 0 &&
{expandedSection === "quality" && ( renderSectionHeader(
t("casting_player.quality"),
"film-outline",
"quality",
)}
{mediaSources.length > 0 && expandedSection === "quality" && (
<View style={{ paddingVertical: 8 }}> <View style={{ paddingVertical: 8 }}>
{mediaSources.map((source) => ( {mediaSources.map((source) => (
<Pressable <Pressable
@@ -176,7 +183,11 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
{/* Audio Tracks - only show if more than one track */} {/* Audio Tracks - only show if more than one track */}
{audioTracks.length > 1 && {audioTracks.length > 1 &&
renderSectionHeader("Audio", "musical-notes", "audio")} renderSectionHeader(
t("casting_player.audio"),
"musical-notes",
"audio",
)}
{audioTracks.length > 1 && expandedSection === "audio" && ( {audioTracks.length > 1 && expandedSection === "audio" && (
<View style={{ paddingVertical: 8 }}> <View style={{ paddingVertical: 8 }}>
{audioTracks.map((track) => ( {audioTracks.map((track) => (
@@ -199,7 +210,9 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
> >
<View> <View>
<Text style={{ color: "white", fontSize: 15 }}> <Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle || track.language || "Unknown"} {track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text> </Text>
{track.codec && ( {track.codec && (
<Text <Text
@@ -219,7 +232,11 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
{/* Subtitle Tracks - only show if subtitles available */} {/* Subtitle Tracks - only show if subtitles available */}
{subtitleTracks.length > 0 && {subtitleTracks.length > 0 &&
renderSectionHeader("Subtitles", "text", "subtitles")} renderSectionHeader(
t("casting_player.subtitles"),
"text",
"subtitles",
)}
{subtitleTracks.length > 0 && expandedSection === "subtitles" && ( {subtitleTracks.length > 0 && expandedSection === "subtitles" && (
<View style={{ paddingVertical: 8 }}> <View style={{ paddingVertical: 8 }}>
<Pressable <Pressable
@@ -238,7 +255,9 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
: "transparent", : "transparent",
}} }}
> >
<Text style={{ color: "white", fontSize: 15 }}>None</Text> <Text style={{ color: "white", fontSize: 15 }}>
{t("casting_player.none")}
</Text>
{selectedSubtitleTrack === null && ( {selectedSubtitleTrack === null && (
<Ionicons name='checkmark' size={20} color='#a855f7' /> <Ionicons name='checkmark' size={20} color='#a855f7' />
)} )}
@@ -263,14 +282,16 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
> >
<View> <View>
<Text style={{ color: "white", fontSize: 15 }}> <Text style={{ color: "white", fontSize: 15 }}>
{track.displayTitle || track.language || "Unknown"} {track.displayTitle ||
track.language ||
t("casting_player.unknown")}
</Text> </Text>
{track.codec && ( {track.codec && (
<Text <Text
style={{ color: "#999", fontSize: 13, marginTop: 2 }} style={{ color: "#999", fontSize: 13, marginTop: 2 }}
> >
{track.codec.toUpperCase()} {track.codec.toUpperCase()}
{track.isForced && " • Forced"} {track.isForced && `${t("casting_player.forced")}`}
</Text> </Text>
)} )}
</View> </View>
@@ -283,7 +304,11 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
)} )}
{/* Playback Speed */} {/* Playback Speed */}
{renderSectionHeader("Playback Speed", "speedometer", "speed")} {renderSectionHeader(
t("casting_player.playback_speed"),
"speedometer",
"speed",
)}
{expandedSection === "speed" && ( {expandedSection === "speed" && (
<View style={{ paddingVertical: 8 }}> <View style={{ paddingVertical: 8 }}>
{PLAYBACK_SPEEDS.map((speed) => ( {PLAYBACK_SPEEDS.map((speed) => (
@@ -299,13 +324,15 @@ export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
alignItems: "center", alignItems: "center",
padding: 16, padding: 16,
backgroundColor: backgroundColor:
playbackSpeed === speed ? "#2a2a2a" : "transparent", Math.abs(playbackSpeed - speed) < 0.01
? "#2a2a2a"
: "transparent",
}} }}
> >
<Text style={{ color: "white", fontSize: 15 }}> <Text style={{ color: "white", fontSize: 15 }}>
{speed === 1 ? "Normal" : `${speed}x`} {speed === 1 ? t("casting_player.normal") : `${speed}x`}
</Text> </Text>
{playbackSpeed === speed && ( {Math.abs(playbackSpeed - speed) < 0.01 && (
<Ionicons name='checkmark' size={20} color='#a855f7' /> <Ionicons name='checkmark' size={20} color='#a855f7' />
)} )}
</Pressable> </Pressable>

View File

@@ -346,12 +346,15 @@ export const Controls: FC<Props> = ({
seek(timeInSeconds * 1000); seek(timeInSeconds * 1000);
// Brief delay ensures the seek operation completes before resuming playback // Brief delay ensures the seek operation completes before resuming playback
// Without this, playback may resume from the old position // Without this, playback may resume from the old position
playTimeoutRef.current = setTimeout(() => { // Only resume if currently playing to avoid overriding user pause
play(); if (isPlaying) {
playTimeoutRef.current = null; playTimeoutRef.current = setTimeout(() => {
}, 200); play();
playTimeoutRef.current = null;
}, 200);
}
}, },
[seek, play], [seek, play, isPlaying],
); );
// Use unified segment skipper for all segment types // Use unified segment skipper for all segment types
@@ -427,7 +430,7 @@ export const Controls: FC<Props> = ({
); );
const skipIntro = activeSegment?.skipSegment || noop; const skipIntro = activeSegment?.skipSegment || noop;
const showSkipCreditButton = activeSegment?.type === "Outro"; const showSkipCreditButton = activeSegment?.type === "Outro";
const skipCredit = outroSkipper.skipSegment; const skipCredit = outroSkipper.skipSegment || noop;
const hasContentAfterCredits = const hasContentAfterCredits =
outroSkipper.currentSegment && maxSeconds outroSkipper.currentSegment && maxSeconds
? outroSkipper.currentSegment.endTime < maxSeconds ? outroSkipper.currentSegment.endTime < maxSeconds

View File

@@ -9,8 +9,9 @@ import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
CastState,
useCastDevice, useCastDevice,
useCastSession, useCastState,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
@@ -30,8 +31,8 @@ export const useCasting = (item: BaseItemDto | null) => {
// Chromecast hooks // Chromecast hooks
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const castDevice = useCastDevice(); const castDevice = useCastDevice();
const castState = useCastState();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const castSession = useCastSession();
// Local state // Local state
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE); const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
@@ -40,9 +41,22 @@ export const useCasting = (item: BaseItemDto | null) => {
const lastReportedProgressRef = useRef(0); const lastReportedProgressRef = useRef(0);
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null); const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for
const stateRef = useRef<CastPlayerState>(DEFAULT_CAST_STATE); // Ref for progress reporting without deps
// Detect which protocol is active // Helper to update both state and ref
const chromecastConnected = castDevice !== null; const updateState = useCallback(
(updater: (prev: CastPlayerState) => CastPlayerState) => {
setState((prev) => {
const next = updater(prev);
stateRef.current = next;
return next;
});
},
[],
);
// Detect which protocol is active - use CastState for reliable detection
const chromecastConnected = castState === CastState.CONNECTED;
// Future: Add detection for other protocols here // Future: Add detection for other protocols here
const activeProtocol: CastProtocol | null = chromecastConnected const activeProtocol: CastProtocol | null = chromecastConnected
@@ -54,7 +68,7 @@ export const useCasting = (item: BaseItemDto | null) => {
// Update current device // Update current device
useEffect(() => { useEffect(() => {
if (chromecastConnected && castDevice) { if (chromecastConnected && castDevice) {
setState((prev) => ({ updateState((prev) => ({
...prev, ...prev,
isConnected: true, isConnected: true,
protocol: "chromecast", protocol: "chromecast",
@@ -65,7 +79,7 @@ export const useCasting = (item: BaseItemDto | null) => {
}, },
})); }));
} else { } else {
setState((prev) => ({ updateState((prev) => ({
...prev, ...prev,
isConnected: false, isConnected: false,
protocol: null, protocol: null,
@@ -78,7 +92,7 @@ export const useCasting = (item: BaseItemDto | null) => {
// Chromecast: Update playback state // Chromecast: Update playback state
useEffect(() => { useEffect(() => {
if (activeProtocol === "chromecast" && mediaStatus) { if (activeProtocol === "chromecast" && mediaStatus) {
setState((prev) => ({ updateState((prev) => ({
...prev, ...prev,
isPlaying: mediaStatus.playerState === "playing", isPlaying: mediaStatus.playerState === "playing",
progress: (mediaStatus.streamPosition || 0) * 1000, progress: (mediaStatus.streamPosition || 0) * 1000,
@@ -86,62 +100,40 @@ export const useCasting = (item: BaseItemDto | null) => {
isBuffering: mediaStatus.playerState === "buffering", isBuffering: mediaStatus.playerState === "buffering",
})); }));
} }
}, [mediaStatus, activeProtocol]); }, [mediaStatus, activeProtocol, updateState]);
// Chromecast: Sync volume from device (both mediaStatus and CastSession) // Chromecast: Sync volume from mediaStatus
useEffect(() => { useEffect(() => {
if (activeProtocol !== "chromecast") return; if (activeProtocol !== "chromecast") return;
// Sync from mediaStatus when available // Sync from mediaStatus when available
if (mediaStatus?.volume !== undefined) { if (mediaStatus?.volume !== undefined) {
setState((prev) => ({ updateState((prev) => ({
...prev, ...prev,
volume: mediaStatus.volume, volume: mediaStatus.volume,
})); }));
} }
}, [mediaStatus?.volume, activeProtocol, updateState]);
// 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) // Progress reporting to Jellyfin (matches native player behavior)
// Uses stateRef to read current progress/volume without adding them as deps
useEffect(() => { useEffect(() => {
if (!isConnected || !item?.Id || !user?.Id || !api) return; if (!isConnected || !item?.Id || !user?.Id || !api) return;
const playStateApi = getPlaystateApi(api); const playStateApi = getPlaystateApi(api);
// Report playback start when media begins (only once per item) // Report playback start when media begins (only once per item)
if (hasReportedStartRef.current !== item.Id && state.progress > 0) { const currentState = stateRef.current;
if (hasReportedStartRef.current !== item.Id && currentState.progress > 0) {
playStateApi playStateApi
.reportPlaybackStart({ .reportPlaybackStart({
playbackStartInfo: { playbackStartInfo: {
ItemId: item.Id, ItemId: item.Id,
PositionTicks: Math.floor(state.progress * 10000), PositionTicks: Math.floor(currentState.progress * 10000),
PlayMethod: PlayMethod:
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay", activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
VolumeLevel: Math.floor(state.volume * 100), VolumeLevel: Math.floor(currentState.volume * 100),
IsMuted: state.volume === 0, IsMuted: currentState.volume === 0,
PlaySessionId: mediaStatus?.mediaInfo?.contentId, PlaySessionId: mediaStatus?.mediaInfo?.contentId,
}, },
}) })
@@ -154,17 +146,18 @@ export const useCasting = (item: BaseItemDto | null) => {
} }
const reportProgress = () => { const reportProgress = () => {
const s = stateRef.current;
// Don't report if no meaningful progress or if buffering // Don't report if no meaningful progress or if buffering
if (state.progress <= 0 || state.isBuffering) return; if (s.progress <= 0 || s.isBuffering) return;
const progressMs = Math.floor(state.progress); const progressMs = Math.floor(s.progress);
const progressTicks = progressMs * 10000; // Convert ms to ticks const progressTicks = progressMs * 10000; // Convert ms to ticks
const progressSeconds = Math.floor(progressMs / 1000); const progressSeconds = Math.floor(progressMs / 1000);
// When paused, always report to keep server in sync // When paused, always report to keep server in sync
// When playing, skip if progress hasn't changed significantly (less than 3 seconds) // When playing, skip if progress hasn't changed significantly (less than 3 seconds)
if ( if (
state.isPlaying && s.isPlaying &&
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3 Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
) { ) {
return; return;
@@ -177,13 +170,11 @@ export const useCasting = (item: BaseItemDto | null) => {
playbackProgressInfo: { playbackProgressInfo: {
ItemId: item.Id, ItemId: item.Id,
PositionTicks: progressTicks, PositionTicks: progressTicks,
IsPaused: !state.isPlaying, IsPaused: !s.isPlaying,
PlayMethod: PlayMethod:
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay", activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
// Add volume level for server tracking VolumeLevel: Math.floor(s.volume * 100),
VolumeLevel: Math.floor(state.volume * 100), IsMuted: s.volume === 0,
IsMuted: state.volume === 0,
// Include play session ID if available
PlaySessionId: mediaStatus?.mediaInfo?.contentId, PlaySessionId: mediaStatus?.mediaInfo?.contentId,
}, },
}) })
@@ -192,23 +183,13 @@ export const useCasting = (item: BaseItemDto | null) => {
}); });
}; };
// Report immediately on play/pause state change // Report progress on a fixed interval, reading latest state from ref
reportProgress(); const interval = setInterval(reportProgress, 10000);
// Report every 5 seconds when paused, every 10 seconds when playing
const interval = setInterval(
reportProgress,
state.isPlaying ? 10000 : 5000,
);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [ }, [
api, api,
item?.Id, item?.Id,
user?.Id, user?.Id,
state.progress,
state.isPlaying,
state.isBuffering, // Add buffering state to dependencies
state.volume,
isConnected, isConnected,
activeProtocol, activeProtocol,
mediaStatus?.mediaInfo?.contentId, mediaStatus?.mediaInfo?.contentId,
@@ -262,14 +243,16 @@ export const useCasting = (item: BaseItemDto | null) => {
// Additional validation for Chromecast // Additional validation for Chromecast
if (activeProtocol === "chromecast") { if (activeProtocol === "chromecast") {
if (positionSeconds > state.duration) { // state.duration is in ms, positionSeconds is in seconds - compare in same unit
const durationSeconds = state.duration / 1000;
if (positionSeconds > durationSeconds) {
console.warn( console.warn(
"[useCasting] Seek position exceeds duration, clamping:", "[useCasting] Seek position exceeds duration, clamping:",
positionSeconds, positionSeconds,
"->", "->",
state.duration, durationSeconds,
); );
await client?.seek({ position: state.duration }); await client?.seek({ position: durationSeconds });
return; return;
} }
await client?.seek({ position: positionSeconds }); await client?.seek({ position: positionSeconds });
@@ -315,6 +298,7 @@ export const useCasting = (item: BaseItemDto | null) => {
} }
setState(DEFAULT_CAST_STATE); setState(DEFAULT_CAST_STATE);
stateRef.current = DEFAULT_CAST_STATE;
// Call callback after stop completes (e.g., to navigate away) // Call callback after stop completes (e.g., to navigate away)
if (onStopComplete) { if (onStopComplete) {
@@ -330,7 +314,7 @@ export const useCasting = (item: BaseItemDto | null) => {
const clampedVolume = Math.max(0, Math.min(1, volume)); const clampedVolume = Math.max(0, Math.min(1, volume));
// Update UI immediately // Update UI immediately
setState((prev) => ({ ...prev, volume: clampedVolume })); updateState((prev) => ({ ...prev, volume: clampedVolume }));
// Debounce API call // Debounce API call
if (volumeDebounceRef.current) { if (volumeDebounceRef.current) {
@@ -341,35 +325,32 @@ export const useCasting = (item: BaseItemDto | null) => {
if (activeProtocol === "chromecast" && client && isConnected) { if (activeProtocol === "chromecast" && client && isConnected) {
// Use setStreamVolume for media stream volume (0.0 - 1.0) // Use setStreamVolume for media stream volume (0.0 - 1.0)
// Physical volume buttons are handled automatically by the framework // Physical volume buttons are handled automatically by the framework
await client.setStreamVolume(clampedVolume).catch((error) => { await client.setStreamVolume(clampedVolume).catch(() => {
console.log( // Ignore errors - session might have ended
"[useCasting] Volume set failed (no session):",
error.message,
);
}); });
} }
// Future: Add volume control for other protocols // Future: Add volume control for other protocols
}, 300); }, 300);
}, },
[client, activeProtocol], [client, activeProtocol, isConnected],
); );
// Controls visibility // Controls visibility
const showControls = useCallback(() => { const showControls = useCallback(() => {
setState((prev) => ({ ...prev, showControls: true })); updateState((prev) => ({ ...prev, showControls: true }));
if (controlsTimeoutRef.current) { if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current); clearTimeout(controlsTimeoutRef.current);
} }
controlsTimeoutRef.current = setTimeout(() => { controlsTimeoutRef.current = setTimeout(() => {
if (state.isPlaying) { if (state.isPlaying) {
setState((prev) => ({ ...prev, showControls: false })); updateState((prev) => ({ ...prev, showControls: false }));
} }
}, 5000); }, 5000);
}, [state.isPlaying]); }, [state.isPlaying, updateState]);
const hideControls = useCallback(() => { const hideControls = useCallback(() => {
setState((prev) => ({ ...prev, showControls: false })); updateState((prev) => ({ ...prev, showControls: false }));
if (controlsTimeoutRef.current) { if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current); clearTimeout(controlsTimeoutRef.current);
} }

View File

@@ -1,109 +0,0 @@
import { Api } from "@jellyfin/sdk";
import { useCallback, useEffect, useState } from "react";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping credits in a media player.
* The player reports time values in milliseconds.
*/
export const useCreditSkipper = (
itemId: string,
currentTime: number,
seek: (ms: number) => void,
play: () => void,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
totalDuration?: number,
) => {
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const lightHapticFeedback = useHaptic("light");
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const totalDurationInSeconds =
totalDuration != null ? msToSeconds(totalDuration) : undefined;
// Regular function (not useCallback) to match useIntroSkipper pattern
const wrappedSeek = (seconds: number) => {
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
itemId,
isOffline,
downloadedFiles,
api,
);
const creditTimestamps = segments?.creditSegments?.[0];
// Determine if there's content after credits (credits don't extend to video end)
// Use a 5-second buffer to account for timing discrepancies
const hasContentAfterCredits = (() => {
if (
!creditTimestamps ||
totalDurationInSeconds == null ||
!Number.isFinite(totalDurationInSeconds)
) {
return false;
}
const creditsEndToVideoEnd =
totalDurationInSeconds - creditTimestamps.endTime;
// If credits end more than 5 seconds before video ends, there's content after
return creditsEndToVideoEnd > 5;
})();
useEffect(() => {
if (creditTimestamps) {
const shouldShow =
currentTimeSeconds > creditTimestamps.startTime &&
currentTimeSeconds < creditTimestamps.endTime;
setShowSkipCreditButton(shouldShow);
} else {
// Reset button state when no credit timestamps exist
if (showSkipCreditButton) {
setShowSkipCreditButton(false);
}
}
}, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
const skipCredit = useCallback(() => {
if (!creditTimestamps) return;
try {
lightHapticFeedback();
// Calculate the target seek position
let seekTarget = creditTimestamps.endTime;
// If we have total duration, ensure we don't seek past the end of the video.
// Some media sources report credit end times that exceed the actual video duration,
// which causes the player to pause/stop when seeking past the end.
// Leave a small buffer (2 seconds) to trigger the natural end-of-video flow
// (next episode countdown, etc.) instead of an abrupt pause.
if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) {
seekTarget = Math.max(0, totalDurationInSeconds - 2);
}
wrappedSeek(seekTarget);
setTimeout(() => {
play();
}, 200);
} catch (error) {
console.error("[CREDIT_SKIPPER] Error skipping credit", error);
}
}, [
creditTimestamps,
lightHapticFeedback,
wrappedSeek,
play,
totalDurationInSeconds,
]);
return { showSkipCreditButton, skipCredit, hasContentAfterCredits };
};

View File

@@ -1,68 +0,0 @@
import { Api } from "@jellyfin/sdk";
import { useCallback, useEffect, useState } from "react";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useSegments } from "@/utils/segments";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
/**
* Custom hook to handle skipping intros in a media player.
* MPV player uses milliseconds for time.
*
* @param {number} currentTime - The current playback time in milliseconds.
*/
export const useIntroSkipper = (
itemId: string,
currentTime: number,
seek: (ms: number) => void,
play: () => void,
isOffline = false,
api: Api | null = null,
downloadedFiles: DownloadedItem[] | undefined = undefined,
) => {
const [showSkipButton, setShowSkipButton] = useState(false);
// Convert ms to seconds for comparison with timestamps
const currentTimeSeconds = msToSeconds(currentTime);
const lightHapticFeedback = useHaptic("light");
const wrappedSeek = (seconds: number) => {
seek(secondsToMs(seconds));
};
const { data: segments } = useSegments(
itemId,
isOffline,
downloadedFiles,
api,
);
const introTimestamps = segments?.introSegments?.[0];
useEffect(() => {
if (introTimestamps) {
const shouldShow =
currentTimeSeconds > introTimestamps.startTime &&
currentTimeSeconds < introTimestamps.endTime;
setShowSkipButton(shouldShow);
} else {
if (showSkipButton) {
setShowSkipButton(false);
}
}
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
const skipIntro = useCallback(() => {
if (!introTimestamps) return;
try {
lightHapticFeedback();
wrappedSeek(introTimestamps.endTime);
setTimeout(() => {
play();
}, 200);
} catch (error) {
console.error("[INTRO_SKIPPER] Error skipping intro", error);
}
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
return { showSkipButton, skipIntro };
};

View File

@@ -33,7 +33,7 @@ export const useSegmentSkipper = ({
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => { }: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
const { settings } = useSettings(); const { settings } = useSettings();
const haptic = useHaptic(); const haptic = useHaptic();
const autoSkipTriggeredRef = useRef(false); const autoSkipTriggeredRef = useRef<string | null>(null);
// Get skip mode based on segment type // Get skip mode based on segment type
const skipMode = (() => { const skipMode = (() => {
@@ -63,7 +63,7 @@ export const useSegmentSkipper = ({
// Skip function with optional haptic feedback // Skip function with optional haptic feedback
const skipSegment = useCallback( const skipSegment = useCallback(
(notifyOrUseHaptics = true) => { (notifyOrUseHaptics = true) => {
if (!currentSegment) return; if (!currentSegment || skipMode === "none") return;
// For Outro segments, prevent seeking past the end // For Outro segments, prevent seeking past the end
if (segmentType === "Outro" && totalDuration) { if (segmentType === "Outro" && totalDuration) {
@@ -78,22 +78,26 @@ export const useSegmentSkipper = ({
haptic(); haptic();
} }
}, },
[currentSegment, segmentType, totalDuration, seek, haptic], [currentSegment, segmentType, totalDuration, seek, haptic, skipMode],
); );
// Auto-skip logic when mode is 'auto' // Auto-skip logic when mode is 'auto'
useEffect(() => { useEffect(() => {
if (skipMode !== "auto" || isPaused) { if (skipMode !== "auto" || isPaused) {
autoSkipTriggeredRef.current = false;
return; return;
} }
if (currentSegment && !autoSkipTriggeredRef.current) { // Track segment identity to avoid re-triggering on pause/unpause
autoSkipTriggeredRef.current = true; const segmentId = currentSegment
? `${currentSegment.startTime}-${currentSegment.endTime}`
: null;
if (currentSegment && autoSkipTriggeredRef.current !== segmentId) {
autoSkipTriggeredRef.current = segmentId;
skipSegment(false); // Don't trigger haptics for auto-skip skipSegment(false); // Don't trigger haptics for auto-skip
} }
if (!currentSegment) { if (!currentSegment) {
autoSkipTriggeredRef.current = false; autoSkipTriggeredRef.current = null;
} }
}, [currentSegment, skipMode, isPaused, skipSegment]); }, [currentSegment, skipMode, isPaused, skipSegment]);

View File

@@ -32,7 +32,7 @@
"skip_preview": "Skip Preview", "skip_preview": "Skip Preview",
"error": "Error", "error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL", "failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.", "an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client Error", "client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast", "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}", "message_from_server": "Message from Server: {{message}}",
@@ -54,6 +54,32 @@
"changing_audio": "Changing audio...", "changing_audio": "Changing audio...",
"changing_subtitles": "Changing subtitles...", "changing_subtitles": "Changing subtitles...",
"season_episode_format": "Season {{season}} • Episode {{episode}}", "season_episode_format": "Season {{season}} • Episode {{episode}}",
"connecting": "Connecting to Chromecast...",
"unknown_device": "Unknown Device",
"ending_at": "Ending at {{time}}",
"unknown": "Unknown",
"connected": "Connected",
"volume": "Volume",
"muted": "Muted",
"disconnect": "Disconnect",
"stop_casting": "Stop Casting",
"disconnecting": "Disconnecting...",
"chromecast": "Chromecast",
"device_name": "Device Name",
"playback_settings": "Playback Settings",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitles",
"none": "None",
"playback_speed": "Playback Speed",
"normal": "Normal",
"episodes": "Episodes",
"season": "Season {{number}}",
"minutes_short": "min",
"episode_label": "Episode {{number}}",
"forced": "Forced",
"device": "Device",
"cancel": "Cancel",
"connection_quality": { "connection_quality": {
"excellent": "Excellent", "excellent": "Excellent",
"good": "Good", "good": "Good",

View File

@@ -21,7 +21,8 @@ export const formatTime = (ms: number): string => {
}; };
/** /**
* Calculate ending time based on current progress and duration * Calculate ending time based on current progress and duration.
* Uses locale-aware formatting when available.
*/ */
export const calculateEndingTime = ( export const calculateEndingTime = (
currentMs: number, currentMs: number,
@@ -29,12 +30,20 @@ export const calculateEndingTime = (
): string => { ): string => {
const remainingMs = durationMs - currentMs; const remainingMs = durationMs - currentMs;
const endTime = new Date(Date.now() + remainingMs); const endTime = new Date(Date.now() + remainingMs);
const hours = endTime.getHours();
const minutes = endTime.getMinutes();
const ampm = hours >= 12 ? "PM" : "AM";
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`; try {
return endTime.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
} catch {
// Fallback for environments without Intl support
const hours = endTime.getHours();
const minutes = endTime.getMinutes();
const ampm = hours >= 12 ? "PM" : "AM";
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`;
}
}; };
/** /**
@@ -76,6 +85,7 @@ export const getPosterUrl = (
* Truncate title to max length with ellipsis * Truncate title to max length with ellipsis
*/ */
export const truncateTitle = (title: string, maxLength: number): string => { export const truncateTitle = (title: string, maxLength: number): string => {
if (maxLength < 4) return title.substring(0, maxLength);
if (title.length <= maxLength) return title; if (title.length <= maxLength) return title;
return `${title.substring(0, maxLength - 3)}...`; return `${title.substring(0, maxLength - 3)}...`;
}; };
@@ -110,7 +120,10 @@ export const getProtocolName = (protocol: CastProtocol): string => {
switch (protocol) { switch (protocol) {
case "chromecast": case "chromecast":
return "Chromecast"; return "Chromecast";
// Future: Add cases for other protocols default: {
const _exhaustive: never = protocol;
return String(_exhaustive);
}
} }
}; };
@@ -123,16 +136,23 @@ export const getProtocolIcon = (
switch (protocol) { switch (protocol) {
case "chromecast": case "chromecast":
return "tv"; return "tv";
// Future: Add icons for other protocols default: {
const _exhaustive: never = protocol;
return "tv";
}
} }
}; };
/** /**
* Format episode info (e.g., "S1 E1" or "Episode 1") * Format episode info (e.g., "S1 E1" or "Episode 1")
* @param seasonNumber - Season number
* @param episodeNumber - Episode number
* @param episodeLabel - Optional label for standalone episode (e.g. translated "Episode")
*/ */
export const formatEpisodeInfo = ( export const formatEpisodeInfo = (
seasonNumber?: number | null, seasonNumber?: number | null,
episodeNumber?: number | null, episodeNumber?: number | null,
episodeLabel = "Episode",
): string => { ): string => {
if ( if (
seasonNumber !== undefined && seasonNumber !== undefined &&
@@ -143,7 +163,7 @@ export const formatEpisodeInfo = (
return `S${seasonNumber} E${episodeNumber}`; return `S${seasonNumber} E${episodeNumber}`;
} }
if (episodeNumber !== undefined && episodeNumber !== null) { if (episodeNumber !== undefined && episodeNumber !== null) {
return `Episode ${episodeNumber}`; return `${episodeLabel} ${episodeNumber}`;
} }
return ""; return "";
}; };

View File

@@ -0,0 +1,85 @@
/**
* Shared helper to build Chromecast media metadata.
* Eliminates duplication between PlayButton, casting-player reloadWithSettings, and loadEpisode.
*/
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { MediaStreamType } from "react-native-google-cast";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
/**
* Build a MediaInfo object suitable for `remoteMediaClient.loadMedia()`.
*/
export const buildCastMediaInfo = ({
item,
streamUrl,
api,
}: {
item: BaseItemDto;
streamUrl: string;
api: Api;
}) => {
const streamDuration = item.RunTimeTicks
? item.RunTimeTicks / 10000000
: undefined;
const buildImages = (urls: (string | null | undefined)[]) =>
urls.filter(Boolean).map((url) => ({ url: url as string }));
const metadata =
item.Type === "Episode"
? {
type: "tvShow" as const,
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: buildImages([
getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
}),
]),
}
: item.Type === "Movie"
? {
type: "movie" as const,
title: item.Name || "",
subtitle: item.Overview || "",
images: buildImages([
getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
}),
]),
}
: {
type: "generic" as const,
title: item.Name || "",
subtitle: item.Overview || "",
images: buildImages([
getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
}),
]),
};
return {
contentId: item.Id,
contentUrl: streamUrl,
contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED,
streamDuration,
customData: item,
metadata,
};
};

View File

@@ -4,6 +4,8 @@
* Architecture allows for future protocols (AirPlay, DLNA, etc.) * Architecture allows for future protocols (AirPlay, DLNA, etc.)
*/ */
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
export type CastProtocol = "chromecast"; export type CastProtocol = "chromecast";
export interface CastDevice { export interface CastDevice {
@@ -16,7 +18,7 @@ export interface CastDevice {
export interface CastPlayerState { export interface CastPlayerState {
isConnected: boolean; isConnected: boolean;
isPlaying: boolean; isPlaying: boolean;
currentItem: any | null; currentItem: BaseItemDto | null;
currentDevice: CastDevice | null; currentDevice: CastDevice | null;
protocol: CastProtocol | null; protocol: CastProtocol | null;
progress: number; progress: number;

View File

@@ -1,147 +0,0 @@
/**
* Chromecast utility helper functions
*/
import { CONNECTION_QUALITY, type ConnectionQuality } from "./options";
/**
* Formats milliseconds to HH:MM:SS or MM:SS
*/
export const formatTime = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (num: number) => num.toString().padStart(2, "0");
if (hours > 0) {
return `${hours}:${pad(minutes)}:${pad(seconds)}`;
}
return `${minutes}:${pad(seconds)}`;
};
/**
* Calculates ending time based on current time and remaining duration
*/
export const calculateEndingTime = (
remainingMs: number,
use24Hour = true,
): string => {
const endTime = new Date(Date.now() + remainingMs);
const hours = endTime.getHours();
const minutes = endTime.getMinutes();
if (use24Hour) {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
const period = hours >= 12 ? "PM" : "AM";
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${period}`;
};
/**
* Determines connection quality based on bitrate and latency
*/
export const getConnectionQuality = (
bitrateMbps: number,
latencyMs?: number,
): ConnectionQuality => {
// Prioritize bitrate, but factor in latency if available
let effectiveBitrate = bitrateMbps;
if (latencyMs !== undefined && latencyMs > 200) {
effectiveBitrate *= 0.7; // Reduce effective quality for high latency
}
if (effectiveBitrate >= CONNECTION_QUALITY.EXCELLENT.min) {
return "EXCELLENT";
}
if (effectiveBitrate >= CONNECTION_QUALITY.GOOD.min) {
return "GOOD";
}
if (effectiveBitrate >= CONNECTION_QUALITY.FAIR.min) {
return "FAIR";
}
return "POOR";
};
/**
* Checks if we should show next episode countdown
*/
export const shouldShowNextEpisodeCountdown = (
remainingMs: number,
hasNextEpisode: boolean,
countdownStartSeconds: number,
): boolean => {
return hasNextEpisode && remainingMs <= countdownStartSeconds * 1000;
};
/**
* Truncates long titles with ellipsis
*/
export const truncateTitle = (title: string, maxLength: number): string => {
if (title.length <= maxLength) return title;
return `${title.substring(0, maxLength - 3)}...`;
};
/**
* Formats episode info (e.g., "S1 E1" or "Episode 1")
*/
export const formatEpisodeInfo = (
seasonNumber?: number | null,
episodeNumber?: number | null,
): string => {
if (
seasonNumber !== undefined &&
seasonNumber !== null &&
episodeNumber !== undefined &&
episodeNumber !== null
) {
return `S${seasonNumber} E${episodeNumber}`;
}
if (episodeNumber !== undefined && episodeNumber !== null) {
return `Episode ${episodeNumber}`;
}
return "";
};
/**
* Gets the appropriate poster URL (season for series, primary for movies)
*/
export const getPosterUrl = (
item: {
Type?: string | null;
ParentBackdropImageTags?: string[] | null;
SeriesId?: string | null;
Id?: string | null;
},
api: { basePath?: string },
): string | null => {
if (!api.basePath) return null;
if (item.Type === "Episode" && item.SeriesId) {
// Use season poster for episodes
return `${api.basePath}/Items/${item.SeriesId}/Images/Primary`;
}
// Use primary image for movies and other types
if (item.Id) {
return `${api.basePath}/Items/${item.Id}/Images/Primary`;
}
return null;
};
/**
* Checks if currently within a segment (intro, credits, etc.)
*/
export const isWithinSegment = (
currentMs: number,
segment: { start: number; end: number } | null,
): boolean => {
if (!segment) return false;
const currentSeconds = currentMs / 1000;
return currentSeconds >= segment.start && currentSeconds <= segment.end;
};

View File

@@ -23,10 +23,10 @@ export const CHROMECAST_CONSTANTS = {
} as const; } as const;
export const CONNECTION_QUALITY = { export const CONNECTION_QUALITY = {
EXCELLENT: { min: 50, label: "Excellent", icon: "signal" }, EXCELLENT: { min: 50, label: "Excellent", icon: "wifi" }, // min Mbps
GOOD: { min: 30, label: "Good", icon: "signal" }, GOOD: { min: 30, label: "Good", icon: "signal" }, // min Mbps
FAIR: { min: 15, label: "Fair", icon: "signal" }, FAIR: { min: 15, label: "Fair", icon: "cellular" }, // min Mbps
POOR: { min: 0, label: "Poor", icon: "signal" }, POOR: { min: 0, label: "Poor", icon: "warning" }, // min Mbps
} as const; } as const;
export type ConnectionQuality = keyof typeof CONNECTION_QUALITY; export type ConnectionQuality = keyof typeof CONNECTION_QUALITY;
@@ -66,5 +66,5 @@ export const DEFAULT_CHROMECAST_STATE: ChromecastPlayerState = {
volume: 1, volume: 1,
isMuted: false, isMuted: false,
currentItemId: null, currentItemId: null,
connectionQuality: "EXCELLENT", connectionQuality: "GOOD",
}; };

View File

@@ -92,9 +92,5 @@ export const chromecast: DeviceProfile = {
Format: "vtt", Format: "vtt",
Method: "Encode", Method: "Encode",
}, },
{
Format: "vtt",
Method: "Encode",
},
], ],
}; };

View File

@@ -91,9 +91,5 @@ export const chromecasth265: DeviceProfile = {
Format: "vtt", Format: "vtt",
Method: "Encode", Method: "Encode",
}, },
{
Format: "vtt",
Method: "Encode",
},
], ],
}; };