diff --git a/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx index 1c2ec8ab..42187481 100644 --- a/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/segment-skip/page.tsx @@ -96,6 +96,7 @@ export default function SegmentSkipPage() { > @@ -119,6 +120,7 @@ export default function SegmentSkipPage() { > @@ -142,6 +144,7 @@ export default function SegmentSkipPage() { > @@ -165,6 +168,7 @@ export default function SegmentSkipPage() { > @@ -190,6 +194,7 @@ export default function SegmentSkipPage() { > diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index d398b44b..95a39b31 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -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(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() { > - Connecting to Chromecast... + {t("casting_player.connecting")} ); @@ -795,7 +727,7 @@ export default function CastingPlayerScreen() { fontWeight: "500", }} > - {currentDevice || "Unknown Device"} + {currentDevice || t("casting_player.unknown_device")} @@ -831,7 +763,10 @@ export default function CastingPlayerScreen() { marginBottom: 6, }} > - {truncateTitle(currentItem.Name || "Unknown", 50)} + {truncateTitle( + currentItem.Name || t("casting_player.unknown"), + 50, + )} {/* 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 */} { - 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)} - Ending at{" "} - {calculateEndingTime(progress * 1000, duration * 1000)} + {t("casting_player.ending_at", { + time: calculateEndingTime(progress * 1000, duration * 1000), + })} {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 }); diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index b5dcac73..216c504f 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -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); }} diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index 75499312..90c0ad4a 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -41,6 +41,8 @@ export function Chromecast({ const isConnected = castState === CastState.CONNECTED; const lastReportedProgressRef = useRef(0); + const playSessionIdRef = useRef(null); + const lastContentIdRef = useRef(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) diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index 24fd135c..d6b03bea 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -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 ( + + {trigger || Open Menu} + + ); + } return ( @@ -353,8 +362,14 @@ const PlatformDropdownComponent = ({ }; return ( - - {trigger || Open Menu} + + + {trigger || Open Menu} + ); }; diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 7915b442..329f3e00 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ router.push("/casting-player"); }); } catch (e) { - console.log(e); + console.error("[PlayButton] Cast error:", e); } } }); diff --git a/components/casting/CastingMiniPlayer.tsx b/components/casting/CastingMiniPlayer.tsx index 595b122c..f2a7b021 100644 --- a/components/casting/CastingMiniPlayer.tsx +++ b/components/casting/CastingMiniPlayer.tsx @@ -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); + }); } }; diff --git a/components/chromecast/ChromecastConnectionMenu.tsx b/components/chromecast/ChromecastConnectionMenu.tsx index 6a57e5b1..1d0c53ad 100644 --- a/components/chromecast/ChromecastConnectionMenu.tsx +++ b/components/chromecast/ChromecastConnectionMenu.tsx @@ -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< - {castDevice?.friendlyName || "Chromecast"} + {castDevice?.friendlyName || t("casting_player.chromecast")} - Connected + {t("casting_player.connected")} @@ -213,9 +215,11 @@ export const ChromecastConnectionMenu: React.FC< marginBottom: 12, }} > - Volume + + {t("casting_player.volume")} + - {isMuted ? "Muted" : `${displayVolume}%`} + {isMuted ? t("casting_player.muted") : `${displayVolume}%`} - Disconnect + {t("casting_player.disconnect")} diff --git a/components/chromecast/ChromecastDeviceSheet.tsx b/components/chromecast/ChromecastDeviceSheet.tsx index b55fb8e8..2ce7dea1 100644 --- a/components/chromecast/ChromecastDeviceSheet.tsx +++ b/components/chromecast/ChromecastDeviceSheet.tsx @@ -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; volume?: number; onVolumeChange?: (volume: number) => Promise; @@ -31,6 +32,7 @@ export const ChromecastDeviceSheet: React.FC = ({ 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 = ({ // 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 = ({ // 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 = ({ } }, [castSession, isMuted]); + // Cleanup debounce timer on unmount + useEffect(() => { + return () => { + if (volumeDebounceRef.current) { + clearTimeout(volumeDebounceRef.current); + } + }; + }, []); + return ( = ({ - Chromecast + {t("casting_player.chromecast")} @@ -208,12 +216,12 @@ export const ChromecastDeviceSheet: React.FC = ({ - Device Name + {t("casting_player.device_name")} - {device?.friendlyName || "Unknown Device"} + {device?.friendlyName || t("casting_player.unknown_device")} {/* Volume control */} @@ -226,9 +234,11 @@ export const ChromecastDeviceSheet: React.FC = ({ marginBottom: 12, }} > - Volume + + {t("casting_player.volume")} + - {isMuted ? "Muted" : `${displayVolume}%`} + {isMuted ? t("casting_player.muted") : `${displayVolume}%`} = ({ - {isDisconnecting ? "Disconnecting..." : "Stop Casting"} + {isDisconnecting + ? t("casting_player.disconnecting") + : t("casting_player.stop_casting")} diff --git a/components/chromecast/ChromecastEpisodeList.tsx b/components/chromecast/ChromecastEpisodeList.tsx index 5235add9..4967cda2 100644 --- a/components/chromecast/ChromecastEpisodeList.tsx +++ b/components/chromecast/ChromecastEpisodeList.tsx @@ -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 = ({ api, }) => { const insets = useSafeAreaInsets(); + const { t } = useTranslation(); const flatListRef = useRef(null); const [selectedSeason, setSelectedSeason] = useState(null); @@ -76,13 +78,14 @@ export const ChromecastEpisodeList: React.FC = ({ ); 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 = ({ }} numberOfLines={1} > - {item.IndexNumber}. {truncateTitle(item.Name || "Unknown", 30)} + {item.IndexNumber}.{" "} + {truncateTitle(item.Name || t("casting_player.unknown"), 30)} {item.Overview && ( = ({ )} {item.RunTimeTicks && ( - {Math.round(item.RunTimeTicks / 600000000)} min + {Math.round(item.RunTimeTicks / 600000000)}{" "} + {t("casting_player.minutes_short")} )} @@ -237,7 +242,7 @@ export const ChromecastEpisodeList: React.FC = ({ }} > - Episodes + {t("casting_player.episodes")} @@ -270,7 +275,7 @@ export const ChromecastEpisodeList: React.FC = ({ fontWeight: selectedSeason === season ? "600" : "400", }} > - Season {season} + {t("casting_player.season", { number: season })} ))} @@ -283,7 +288,7 @@ export const ChromecastEpisodeList: React.FC = ({ ref={flatListRef} data={filteredEpisodes} renderItem={renderEpisode} - keyExtractor={(item) => item.Id || ""} + keyExtractor={(item, index) => item.Id || `episode-${index}`} contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 16, diff --git a/components/chromecast/ChromecastSettingsMenu.tsx b/components/chromecast/ChromecastSettingsMenu.tsx index 14f18325..450d1543 100644 --- a/components/chromecast/ChromecastSettingsMenu.tsx +++ b/components/chromecast/ChromecastSettingsMenu.tsx @@ -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 = ({ onPlaybackSpeedChange, }) => { const insets = useSafeAreaInsets(); + const { t } = useTranslation(); const [expandedSection, setExpandedSection] = useState(null); const toggleSection = (section: string) => { @@ -124,7 +126,7 @@ export const ChromecastSettingsMenu: React.FC = ({ }} > - Playback Settings + {t("casting_player.playback_settings")} @@ -132,9 +134,14 @@ export const ChromecastSettingsMenu: React.FC = ({ - {/* 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" && ( {mediaSources.map((source) => ( = ({ {/* 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" && ( {audioTracks.map((track) => ( @@ -199,7 +210,9 @@ export const ChromecastSettingsMenu: React.FC = ({ > - {track.displayTitle || track.language || "Unknown"} + {track.displayTitle || + track.language || + t("casting_player.unknown")} {track.codec && ( = ({ {/* 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" && ( = ({ : "transparent", }} > - None + + {t("casting_player.none")} + {selectedSubtitleTrack === null && ( )} @@ -263,14 +282,16 @@ export const ChromecastSettingsMenu: React.FC = ({ > - {track.displayTitle || track.language || "Unknown"} + {track.displayTitle || + track.language || + t("casting_player.unknown")} {track.codec && ( {track.codec.toUpperCase()} - {track.isForced && " • Forced"} + {track.isForced && ` • ${t("casting_player.forced")}`} )} @@ -283,7 +304,11 @@ export const ChromecastSettingsMenu: React.FC = ({ )} {/* Playback Speed */} - {renderSectionHeader("Playback Speed", "speedometer", "speed")} + {renderSectionHeader( + t("casting_player.playback_speed"), + "speedometer", + "speed", + )} {expandedSection === "speed" && ( {PLAYBACK_SPEEDS.map((speed) => ( @@ -299,13 +324,15 @@ export const ChromecastSettingsMenu: React.FC = ({ alignItems: "center", padding: 16, backgroundColor: - playbackSpeed === speed ? "#2a2a2a" : "transparent", + Math.abs(playbackSpeed - speed) < 0.01 + ? "#2a2a2a" + : "transparent", }} > - {speed === 1 ? "Normal" : `${speed}x`} + {speed === 1 ? t("casting_player.normal") : `${speed}x`} - {playbackSpeed === speed && ( + {Math.abs(playbackSpeed - speed) < 0.01 && ( )} diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 674a6dfd..2e7e89a4 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -346,12 +346,15 @@ export const Controls: FC = ({ 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 = ({ ); 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 diff --git a/hooks/useCasting.ts b/hooks/useCasting.ts index 31708129..78021831 100644 --- a/hooks/useCasting.ts +++ b/hooks/useCasting.ts @@ -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(DEFAULT_CAST_STATE); @@ -40,9 +41,22 @@ export const useCasting = (item: BaseItemDto | null) => { const lastReportedProgressRef = useRef(0); const volumeDebounceRef = useRef(null); const hasReportedStartRef = useRef(null); // Track which item we reported start for + const stateRef = useRef(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); } diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts deleted file mode 100644 index 40c1d695..00000000 --- a/hooks/useCreditSkipper.ts +++ /dev/null @@ -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 }; -}; diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts deleted file mode 100644 index eeed9833..00000000 --- a/hooks/useIntroSkipper.ts +++ /dev/null @@ -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 }; -}; diff --git a/hooks/useSegmentSkipper.ts b/hooks/useSegmentSkipper.ts index a3b54a5c..57c8dcc1 100644 --- a/hooks/useSegmentSkipper.ts +++ b/hooks/useSegmentSkipper.ts @@ -33,7 +33,7 @@ export const useSegmentSkipper = ({ }: UseSegmentSkipperProps): UseSegmentSkipperReturn => { const { settings } = useSettings(); const haptic = useHaptic(); - const autoSkipTriggeredRef = useRef(false); + const autoSkipTriggeredRef = useRef(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]); diff --git a/translations/en.json b/translations/en.json index 4c56faf8..15458e94 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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", diff --git a/utils/casting/helpers.ts b/utils/casting/helpers.ts index 887e7609..a6a154e5 100644 --- a/utils/casting/helpers.ts +++ b/utils/casting/helpers.ts @@ -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 ""; }; diff --git a/utils/casting/mediaInfo.ts b/utils/casting/mediaInfo.ts new file mode 100644 index 00000000..4ca0e5c4 --- /dev/null +++ b/utils/casting/mediaInfo.ts @@ -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, + }; +}; diff --git a/utils/casting/types.ts b/utils/casting/types.ts index 56ee5f4a..a6d43400 100644 --- a/utils/casting/types.ts +++ b/utils/casting/types.ts @@ -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; diff --git a/utils/chromecast/helpers.ts b/utils/chromecast/helpers.ts deleted file mode 100644 index 9054b385..00000000 --- a/utils/chromecast/helpers.ts +++ /dev/null @@ -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; -}; diff --git a/utils/chromecast/options.ts b/utils/chromecast/options.ts index 9c6fd0bf..510f3753 100644 --- a/utils/chromecast/options.ts +++ b/utils/chromecast/options.ts @@ -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", }; diff --git a/utils/profiles/chromecast.ts b/utils/profiles/chromecast.ts index 2863b4b0..c09f4111 100644 --- a/utils/profiles/chromecast.ts +++ b/utils/profiles/chromecast.ts @@ -92,9 +92,5 @@ export const chromecast: DeviceProfile = { Format: "vtt", Method: "Encode", }, - { - Format: "vtt", - Method: "Encode", - }, ], }; diff --git a/utils/profiles/chromecasth265.ts b/utils/profiles/chromecasth265.ts index 315bcdff..6e5f9fb6 100644 --- a/utils/profiles/chromecasth265.ts +++ b/utils/profiles/chromecasth265.ts @@ -91,9 +91,5 @@ export const chromecasth265: DeviceProfile = { Format: "vtt", Method: "Encode", }, - { - Format: "vtt", - Method: "Encode", - }, ], };