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

View File

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

View File

@@ -939,7 +939,7 @@ export default function page() {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
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);
}}

View File

@@ -41,6 +41,8 @@ export function Chromecast({
const isConnected = castState === CastState.CONNECTED;
const lastReportedProgressRef = useRef(0);
const playSessionIdRef = useRef<string | null>(null);
const lastContentIdRef = useRef<string | null>(null);
const discoveryAttempts = useRef(0);
const maxDiscoveryAttempts = 3;
const hasLoggedDevices = useRef(false);
@@ -121,6 +123,13 @@ export function Chromecast({
}
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 isPaused = mediaStatus.playerState === "paused";
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
@@ -131,7 +140,7 @@ export function Chromecast({
PositionTicks: positionTicks,
IsPaused: isPaused,
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
PlaySessionId: contentId,
PlaySessionId: playSessionIdRef.current || contentId,
};
getPlaystateApi(api)

View File

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

View File

@@ -9,7 +9,6 @@ import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
MediaPlayerState,
MediaStreamType,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -33,8 +32,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
@@ -112,7 +110,11 @@ export const PlayButton: React.FC<Props> = ({
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const options = [
t("casting_player.chromecast"),
t("casting_player.device"),
t("casting_player.cancel"),
];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
@@ -181,17 +183,6 @@ export const PlayButton: React.FC<Props> = ({
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) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
@@ -201,80 +192,16 @@ export const PlayButton: React.FC<Props> = ({
return;
}
// Calculate start time in seconds from playback position
const startTimeSeconds =
(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
.loadMedia({
mediaInfo: {
contentId: item.Id,
contentUrl: data?.url,
contentType: "video/mp4",
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,
})!,
},
],
},
},
mediaInfo: buildCastMediaInfo({
item,
streamUrl: data.url,
api,
}),
startTime: startTimeSeconds,
})
.then(() => {
@@ -285,7 +212,7 @@ export const PlayButton: React.FC<Props> = ({
router.push("/casting-player");
});
} catch (e) {
console.log(e);
console.error("[PlayButton] Cast error:", e);
}
}
});

View File

@@ -110,9 +110,10 @@ export const CastingMiniPlayer: React.FC = () => {
if (
currentItem.Type === "Episode" &&
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}`;
}
@@ -146,12 +147,15 @@ export const CastingMiniPlayer: React.FC = () => {
router.push("/casting-player");
};
const handleTogglePlayPause = (e: any) => {
e.stopPropagation();
const handleTogglePlayPause = () => {
if (isPlaying) {
remoteMediaClient?.pause();
remoteMediaClient?.pause()?.catch((error: unknown) => {
console.error("[CastingMiniPlayer] Pause error:", error);
});
} 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 React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -24,6 +25,7 @@ export const ChromecastConnectionMenu: React.FC<
ChromecastConnectionMenuProps
> = ({ visible, onClose, onDisconnect }) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const castDevice = useCastDevice();
const castSession = useCastSession();
@@ -191,10 +193,10 @@ export const ChromecastConnectionMenu: React.FC<
<Text
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
>
{castDevice?.friendlyName || "Chromecast"}
{castDevice?.friendlyName || t("casting_player.chromecast")}
</Text>
<Text style={{ color: protocolColor, fontSize: 12 }}>
Connected
{t("casting_player.connected")}
</Text>
</View>
</View>
@@ -213,9 +215,11 @@ export const ChromecastConnectionMenu: React.FC<
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 }}>
{isMuted ? "Muted" : `${displayVolume}%`}
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
</Text>
</View>
<View
@@ -255,7 +259,15 @@ export const ChromecastConnectionMenu: React.FC<
handleVolumeChange(value);
if (isMuted) {
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}
@@ -288,7 +300,7 @@ export const ChromecastConnectionMenu: React.FC<
<Text
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
>
Disconnect
{t("casting_player.disconnect")}
</Text>
</Pressable>
</View>

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,9 @@ import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import {
CastState,
useCastDevice,
useCastSession,
useCastState,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
@@ -30,8 +31,8 @@ export const useCasting = (item: BaseItemDto | null) => {
// Chromecast hooks
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const castState = useCastState();
const mediaStatus = useMediaStatus();
const castSession = useCastSession();
// Local state
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
@@ -40,9 +41,22 @@ export const useCasting = (item: BaseItemDto | 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
const stateRef = useRef<CastPlayerState>(DEFAULT_CAST_STATE); // Ref for progress reporting without deps
// Detect which protocol is active
const chromecastConnected = castDevice !== null;
// Helper to update both state and ref
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
const activeProtocol: CastProtocol | null = chromecastConnected
@@ -54,7 +68,7 @@ export const useCasting = (item: BaseItemDto | null) => {
// Update current device
useEffect(() => {
if (chromecastConnected && castDevice) {
setState((prev) => ({
updateState((prev) => ({
...prev,
isConnected: true,
protocol: "chromecast",
@@ -65,7 +79,7 @@ export const useCasting = (item: BaseItemDto | null) => {
},
}));
} else {
setState((prev) => ({
updateState((prev) => ({
...prev,
isConnected: false,
protocol: null,
@@ -78,7 +92,7 @@ export const useCasting = (item: BaseItemDto | null) => {
// Chromecast: Update playback state
useEffect(() => {
if (activeProtocol === "chromecast" && mediaStatus) {
setState((prev) => ({
updateState((prev) => ({
...prev,
isPlaying: mediaStatus.playerState === "playing",
progress: (mediaStatus.streamPosition || 0) * 1000,
@@ -86,62 +100,40 @@ export const useCasting = (item: BaseItemDto | null) => {
isBuffering: mediaStatus.playerState === "buffering",
}));
}
}, [mediaStatus, activeProtocol]);
}, [mediaStatus, activeProtocol, updateState]);
// Chromecast: Sync volume from device (both mediaStatus and CastSession)
// Chromecast: Sync volume from mediaStatus
useEffect(() => {
if (activeProtocol !== "chromecast") return;
// Sync from mediaStatus when available
if (mediaStatus?.volume !== undefined) {
setState((prev) => ({
updateState((prev) => ({
...prev,
volume: mediaStatus.volume,
}));
}
// 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]);
}, [mediaStatus?.volume, activeProtocol, updateState]);
// Progress reporting to Jellyfin (matches native player behavior)
// Uses stateRef to read current progress/volume without adding them as deps
useEffect(() => {
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) {
const currentState = stateRef.current;
if (hasReportedStartRef.current !== item.Id && currentState.progress > 0) {
playStateApi
.reportPlaybackStart({
playbackStartInfo: {
ItemId: item.Id,
PositionTicks: Math.floor(state.progress * 10000),
PositionTicks: Math.floor(currentState.progress * 10000),
PlayMethod:
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
VolumeLevel: Math.floor(state.volume * 100),
IsMuted: state.volume === 0,
VolumeLevel: Math.floor(currentState.volume * 100),
IsMuted: currentState.volume === 0,
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
},
})
@@ -154,17 +146,18 @@ export const useCasting = (item: BaseItemDto | null) => {
}
const reportProgress = () => {
const s = stateRef.current;
// 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 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 &&
s.isPlaying &&
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
) {
return;
@@ -177,13 +170,11 @@ export const useCasting = (item: BaseItemDto | null) => {
playbackProgressInfo: {
ItemId: item.Id,
PositionTicks: progressTicks,
IsPaused: !state.isPlaying,
IsPaused: !s.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
VolumeLevel: Math.floor(s.volume * 100),
IsMuted: s.volume === 0,
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
},
})
@@ -192,23 +183,13 @@ export const useCasting = (item: BaseItemDto | null) => {
});
};
// 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,
);
// Report progress on a fixed interval, reading latest state from ref
const interval = setInterval(reportProgress, 10000);
return () => clearInterval(interval);
}, [
api,
item?.Id,
user?.Id,
state.progress,
state.isPlaying,
state.isBuffering, // Add buffering state to dependencies
state.volume,
isConnected,
activeProtocol,
mediaStatus?.mediaInfo?.contentId,
@@ -262,14 +243,16 @@ export const useCasting = (item: BaseItemDto | null) => {
// Additional validation for 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(
"[useCasting] Seek position exceeds duration, clamping:",
positionSeconds,
"->",
state.duration,
durationSeconds,
);
await client?.seek({ position: state.duration });
await client?.seek({ position: durationSeconds });
return;
}
await client?.seek({ position: positionSeconds });
@@ -315,6 +298,7 @@ export const useCasting = (item: BaseItemDto | null) => {
}
setState(DEFAULT_CAST_STATE);
stateRef.current = DEFAULT_CAST_STATE;
// Call callback after stop completes (e.g., to navigate away)
if (onStopComplete) {
@@ -330,7 +314,7 @@ export const useCasting = (item: BaseItemDto | null) => {
const clampedVolume = Math.max(0, Math.min(1, volume));
// Update UI immediately
setState((prev) => ({ ...prev, volume: clampedVolume }));
updateState((prev) => ({ ...prev, volume: clampedVolume }));
// Debounce API call
if (volumeDebounceRef.current) {
@@ -341,35 +325,32 @@ export const useCasting = (item: BaseItemDto | null) => {
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):",
error.message,
);
await client.setStreamVolume(clampedVolume).catch(() => {
// Ignore errors - session might have ended
});
}
// Future: Add volume control for other protocols
}, 300);
},
[client, activeProtocol],
[client, activeProtocol, isConnected],
);
// Controls visibility
const showControls = useCallback(() => {
setState((prev) => ({ ...prev, showControls: true }));
updateState((prev) => ({ ...prev, showControls: true }));
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
controlsTimeoutRef.current = setTimeout(() => {
if (state.isPlaying) {
setState((prev) => ({ ...prev, showControls: false }));
updateState((prev) => ({ ...prev, showControls: false }));
}
}, 5000);
}, [state.isPlaying]);
}, [state.isPlaying, updateState]);
const hideControls = useCallback(() => {
setState((prev) => ({ ...prev, showControls: false }));
updateState((prev) => ({ ...prev, showControls: false }));
if (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 => {
const { settings } = useSettings();
const haptic = useHaptic();
const autoSkipTriggeredRef = useRef(false);
const autoSkipTriggeredRef = useRef<string | null>(null);
// Get skip mode based on segment type
const skipMode = (() => {
@@ -63,7 +63,7 @@ export const useSegmentSkipper = ({
// Skip function with optional haptic feedback
const skipSegment = useCallback(
(notifyOrUseHaptics = true) => {
if (!currentSegment) return;
if (!currentSegment || skipMode === "none") return;
// For Outro segments, prevent seeking past the end
if (segmentType === "Outro" && totalDuration) {
@@ -78,22 +78,26 @@ export const useSegmentSkipper = ({
haptic();
}
},
[currentSegment, segmentType, totalDuration, seek, haptic],
[currentSegment, segmentType, totalDuration, seek, haptic, skipMode],
);
// Auto-skip logic when mode is 'auto'
useEffect(() => {
if (skipMode !== "auto" || isPaused) {
autoSkipTriggeredRef.current = false;
return;
}
if (currentSegment && !autoSkipTriggeredRef.current) {
autoSkipTriggeredRef.current = true;
// Track segment identity to avoid re-triggering on pause/unpause
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
}
if (!currentSegment) {
autoSkipTriggeredRef.current = false;
autoSkipTriggeredRef.current = null;
}
}, [currentSegment, skipMode, isPaused, skipSegment]);

View File

@@ -32,7 +32,7 @@
"skip_preview": "Skip Preview",
"error": "Error",
"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",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",
@@ -54,6 +54,32 @@
"changing_audio": "Changing audio...",
"changing_subtitles": "Changing subtitles...",
"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": {
"excellent": "Excellent",
"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 = (
currentMs: number,
@@ -29,12 +30,20 @@ export const calculateEndingTime = (
): string => {
const remainingMs = durationMs - currentMs;
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
*/
export const truncateTitle = (title: string, maxLength: number): string => {
if (maxLength < 4) return title.substring(0, maxLength);
if (title.length <= maxLength) return title;
return `${title.substring(0, maxLength - 3)}...`;
};
@@ -110,7 +120,10 @@ export const getProtocolName = (protocol: CastProtocol): string => {
switch (protocol) {
case "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) {
case "chromecast":
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")
* @param seasonNumber - Season number
* @param episodeNumber - Episode number
* @param episodeLabel - Optional label for standalone episode (e.g. translated "Episode")
*/
export const formatEpisodeInfo = (
seasonNumber?: number | null,
episodeNumber?: number | null,
episodeLabel = "Episode",
): string => {
if (
seasonNumber !== undefined &&
@@ -143,7 +163,7 @@ export const formatEpisodeInfo = (
return `S${seasonNumber} E${episodeNumber}`;
}
if (episodeNumber !== undefined && episodeNumber !== null) {
return `Episode ${episodeNumber}`;
return `${episodeLabel} ${episodeNumber}`;
}
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.)
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
export type CastProtocol = "chromecast";
export interface CastDevice {
@@ -16,7 +18,7 @@ export interface CastDevice {
export interface CastPlayerState {
isConnected: boolean;
isPlaying: boolean;
currentItem: any | null;
currentItem: BaseItemDto | null;
currentDevice: CastDevice | null;
protocol: CastProtocol | null;
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;
export const CONNECTION_QUALITY = {
EXCELLENT: { min: 50, label: "Excellent", icon: "signal" },
GOOD: { min: 30, label: "Good", icon: "signal" },
FAIR: { min: 15, label: "Fair", icon: "signal" },
POOR: { min: 0, label: "Poor", icon: "signal" },
EXCELLENT: { min: 50, label: "Excellent", icon: "wifi" }, // min Mbps
GOOD: { min: 30, label: "Good", icon: "signal" }, // min Mbps
FAIR: { min: 15, label: "Fair", icon: "cellular" }, // min Mbps
POOR: { min: 0, label: "Poor", icon: "warning" }, // min Mbps
} as const;
export type ConnectionQuality = keyof typeof CONNECTION_QUALITY;
@@ -66,5 +66,5 @@ export const DEFAULT_CHROMECAST_STATE: ChromecastPlayerState = {
volume: 1,
isMuted: false,
currentItemId: null,
connectionQuality: "EXCELLENT",
connectionQuality: "GOOD",
};

View File

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

View File

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