diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2111dd2e..3a6f475d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,7 +3,7 @@ ## Project Overview Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). -It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs, +It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs, and provides seamless media streaming with offline capabilities and Chromecast support. ## Main Technologies @@ -40,9 +40,30 @@ and provides seamless media streaming with offline capabilities and Chromecast s - `scripts/` – Automation scripts (Node.js, Bash) - `plugins/` – Expo/Metro plugins -## Coding Standards +## Code Quality Standards +**CRITICAL: Code must be production-ready, reliable, and maintainable** + +### Type Safety - Use TypeScript for ALL files (no .js files) +- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards +- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations) +- When facing type issues, create proper type definitions and helper functions instead of using `any` +- Use type assertions (`as`) only as a last resort with clear documentation explaining why +- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts +- Enable and respect strict TypeScript compiler options +- Define explicit return types for functions +- Use discriminated unions for complex state + +### Code Reliability +- Implement comprehensive error handling with try-catch blocks +- Validate all external inputs (API responses, user input, query params) +- Handle edge cases explicitly (empty arrays, null, undefined) +- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately +- Add runtime checks for critical operations +- Implement proper loading and error states in components + +### Best Practices - Use descriptive English names for variables, functions, and components - Prefer functional React components with hooks - Use Jotai atoms for global state management @@ -50,8 +71,10 @@ and provides seamless media streaming with offline capabilities and Chromecast s - Follow BiomeJS formatting and linting rules - Use `const` over `let`, avoid `var` entirely - Implement proper error boundaries -- Use React.memo() for performance optimization +- Use React.memo() for performance optimization when needed - Handle both mobile and TV navigation patterns +- Write self-documenting code with clear intent +- Add comments only when code complexity requires explanation ## API Integration @@ -85,6 +108,18 @@ Exemples: - `fix(auth): handle expired JWT tokens` - `chore(deps): update Jellyfin SDK` +## Internationalization (i18n) + +- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates +- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.): + - **NEVER add or remove keys** - Crowdin manages the key structure + - **Editing translation values is safe** - Bidirectional sync handles merges + - Prefer letting Crowdin translators update values, but direct edits work if needed +- **Crowdin workflow**: + - New keys added to `en.json` sync to Crowdin automatically + - Approved translations sync back to language files via GitHub integration + - The source of truth is `en.json` for structure, Crowdin for translations + ## Special Instructions - Prioritize cross-platform compatibility (mobile + TV) diff --git a/app/(auth)/chromecast-player.tsx b/app/(auth)/chromecast-player.tsx new file mode 100644 index 00000000..535f2936 --- /dev/null +++ b/app/(auth)/chromecast-player.tsx @@ -0,0 +1,584 @@ +/** + * Full Chromecast Player Modal + * Displays when user taps mini player or cast button during playback + */ + +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { useRouter } from "expo-router"; +import { useAtomValue } from "jotai"; +import React, { useCallback, useMemo, useState } from "react"; +import { + ActivityIndicator, + Modal, + Pressable, + useWindowDimensions, + View, +} from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Animated, { + FadeIn, + FadeOut, + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useChromecastPlayer } from "@/components/chromecast/hooks/useChromecastPlayer"; +import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments"; +import { Text } from "@/components/common/Text"; +import { useTrickplay } from "@/hooks/useTrickplay"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { + formatEpisodeInfo, + getPosterUrl, + truncateTitle, +} from "@/utils/chromecast/helpers"; +import { CHROMECAST_CONSTANTS } from "@/utils/chromecast/options"; + +interface ChromecastPlayerProps { + visible: boolean; + onClose: () => void; +} + +export const ChromecastPlayer: React.FC = ({ + visible, + onClose, +}) => { + const _router = useRouter(); + const insets = useSafeAreaInsets(); + const { height: screenHeight, width: screenWidth } = useWindowDimensions(); + const api = useAtomValue(apiAtom); + + const { + playerState, + showControls, + currentItem, + nextItem, + castDevice, + play, + pause, + stop, + togglePlay, + seek, + skipForward, + skipBackward, + disconnect, + setShowControls, + currentTime, + remainingTime, + endingTime, + showNextEpisodeCountdown, + settings, + } = useChromecastPlayer(); + + const { segments, currentSegment, skipSegment, hasIntro, hasCredits } = + useChromecastSegments(currentItem, playerState.progress); + + const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = + useTrickplay(currentItem); + + const [_showMenu, setShowMenu] = useState(false); + const [_showDeviceSheet, setShowDeviceSheet] = useState(false); + const [_showEpisodeList, setShowEpisodeList] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(false); + + // Slider values + const progress = useSharedValue(playerState.progress); + const min = useSharedValue(0); + const max = useSharedValue(playerState.duration); + const isSeeking = useSharedValue(false); + + // Update slider when player state changes + React.useEffect(() => { + if (!isSeeking.value) { + progress.value = playerState.progress; + } + max.value = playerState.duration; + }, [playerState.progress, playerState.duration, isSeeking]); + + // Swipe down to dismiss gesture + const translateY = useSharedValue(0); + const context = useSharedValue({ y: 0 }); + + const gesture = Gesture.Pan() + .onStart(() => { + context.value = { y: translateY.value }; + }) + .onUpdate((event) => { + if (event.translationY > 0) { + translateY.value = context.value.y + event.translationY; + } + }) + .onEnd((event) => { + if (event.translationY > 100) { + translateY.value = withTiming(screenHeight, {}, () => { + runOnJS(onClose)(); + }); + } else { + translateY.value = withSpring(0); + } + }); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); + + const posterUrl = useMemo(() => { + if (!currentItem || !api) return null; + return getPosterUrl(currentItem, api); + }, [currentItem, api]); + + const handleSliderChange = useCallback( + (value: number) => { + progress.value = value; + if (trickplayInfo && currentItem) { + calculateTrickplayUrl(value); + } + }, + [calculateTrickplayUrl, trickplayInfo, currentItem], + ); + + const handleSliderComplete = useCallback( + (value: number) => { + isSeeking.value = false; + seek(value); + }, + [seek, isSeeking], + ); + + if (!playerState.isConnected || !visible) { + return null; + } + + return ( + + + + + {/* Header - Collapsible */} + + + {/* Collapse arrow */} + setIsCollapsed(!isCollapsed)} + style={{ padding: 4 }} + > + + + + {/* Title and episode info */} + + {currentItem && ( + <> + + {truncateTitle( + currentItem.Name || "Unknown", + isCollapsed ? 50 : 35, + )} + + {!isCollapsed && ( + + {formatEpisodeInfo( + currentItem.ParentIndexNumber, + currentItem.IndexNumber, + )} + {currentItem.SeriesName && + ` • ${truncateTitle(currentItem.SeriesName, 25)}`} + + )} + + )} + + + {/* Connection quality indicator */} + + + + + + + {/* Main content area */} + + {/* Poster */} + + {posterUrl ? ( + + ) : ( + + + + )} + + {/* Buffering indicator */} + {playerState.isBuffering && ( + + + + )} + + + {/* Current segment indicator */} + {currentSegment && ( + + + {currentSegment.type.toUpperCase()} DETECTED + + + )} + + + {/* Bottom controls */} + {showControls && ( + + {/* Time display */} + + + {currentTime} + + + {remainingTime} + + + + {/* Progress slider */} + + { + isSeeking.value = true; + }} + onValueChange={handleSliderChange} + onSlidingComplete={handleSliderComplete} + /> + + + {/* Ending time */} + + Ending at {endingTime} + + + {/* Control buttons row */} + + {/* Skip segment button */} + {currentSegment && ( + skipSegment(seek)} + style={{ + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: "#e50914", + borderRadius: 4, + }} + > + + Skip {currentSegment.type} + + + )} + + {/* Episode list button */} + {currentItem?.Type === "Episode" && ( + setShowEpisodeList(true)} + style={{ padding: 8 }} + > + + + )} + + {/* Settings menu */} + setShowMenu(true)} + style={{ padding: 8 }} + > + + + + {/* Chromecast device info */} + setShowDeviceSheet(true)} + style={{ padding: 8 }} + > + + + + + {/* Playback controls */} + + {/* Rewind */} + + + + + {settings?.rewindSkipTime || 15} + + + + + {/* Play/Pause */} + + + + + {/* Forward */} + + + + + {settings?.forwardSkipTime || 15} + + + + + {/* Stop */} + + + + + + )} + + {/* Next episode countdown */} + {showNextEpisodeCountdown && nextItem && ( + + + Next: {truncateTitle(nextItem.Name || "Unknown", 40)} + + + Starting in{" "} + {Math.ceil( + (playerState.duration - playerState.progress) / 1000, + )} + s + + + )} + + + + + {/* TODO: Add settings menu modal */} + {/* TODO: Add device info sheet modal */} + {/* TODO: Add episode list modal */} + + ); +}; diff --git a/bun.lock b/bun.lock index bdcea301..38ce3d97 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "streamyfin", diff --git a/components/chromecast/ChromecastMiniPlayer.tsx b/components/chromecast/ChromecastMiniPlayer.tsx new file mode 100644 index 00000000..c52b48dd --- /dev/null +++ b/components/chromecast/ChromecastMiniPlayer.tsx @@ -0,0 +1,196 @@ +/** + * Mini Chromecast player bar shown at the bottom of the screen + * Similar to music player mini bar + */ + +import { Ionicons } from "@expo/vector-icons"; +import { useRouter } from "expo-router"; +import React from "react"; +import { Pressable, View } from "react-native"; +import Animated, { + FadeIn, + FadeOut, + SlideInDown, + SlideOutDown, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { formatEpisodeInfo, truncateTitle } from "@/utils/chromecast/helpers"; +import { CHROMECAST_CONSTANTS } from "@/utils/chromecast/options"; +import { useChromecastPlayer } from "./hooks/useChromecastPlayer"; + +export const ChromecastMiniPlayer: React.FC = () => { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { playerState, currentItem, togglePlay, showNextEpisodeCountdown } = + useChromecastPlayer(); + + // Don't show if not connected or no media + if (!playerState.isConnected || !playerState.currentItemId) { + return null; + } + + const handlePress = () => { + router.push("/chromecast-player"); + }; + + const progress = + playerState.duration > 0 + ? (playerState.progress / playerState.duration) * 100 + : 0; + + return ( + + {/* Progress bar */} + + + + + + + {/* Cast icon */} + + + + + {/* Media info */} + + {currentItem && ( + <> + + {truncateTitle(currentItem.Name || "Unknown", 40)} + + + + {formatEpisodeInfo( + currentItem.ParentIndexNumber, + currentItem.IndexNumber, + )} + + {showNextEpisodeCountdown && ( + + Next episode starting... + + )} + + + )} + {!currentItem && ( + <> + + Casting to {playerState.deviceName || "Chromecast"} + + + {playerState.isPlaying ? "Playing" : "Paused"} + + + )} + + + {/* Play/Pause button */} + { + e.stopPropagation(); + togglePlay(); + }} + style={{ + width: 48, + height: 48, + justifyContent: "center", + alignItems: "center", + }} + > + {playerState.isBuffering ? ( + + ) : ( + + )} + + + + + ); +}; diff --git a/components/chromecast/hooks/useChromecastPlayer.ts b/components/chromecast/hooks/useChromecastPlayer.ts new file mode 100644 index 00000000..ef301fe8 --- /dev/null +++ b/components/chromecast/hooks/useChromecastPlayer.ts @@ -0,0 +1,239 @@ +/** + * Main Chromecast player hook - handles all playback logic and state + */ + +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCastDevice, + useMediaStatus, + useRemoteMediaClient, +} from "react-native-google-cast"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { + calculateEndingTime, + formatTime, + shouldShowNextEpisodeCountdown, +} from "@/utils/chromecast/helpers"; +import { + CHROMECAST_CONSTANTS, + type ChromecastPlayerState, + DEFAULT_CHROMECAST_STATE, +} from "@/utils/chromecast/options"; + +export const useChromecastPlayer = () => { + const client = useRemoteMediaClient(); + const castDevice = useCastDevice(); + const mediaStatus = useMediaStatus(); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + const { settings } = useSettings(); + + const [playerState, setPlayerState] = useState( + DEFAULT_CHROMECAST_STATE, + ); + const [showControls, setShowControls] = useState(true); + const [currentItem, _setCurrentItem] = useState(null); + const [nextItem, _setNextItem] = useState(null); + + const lastReportedProgressRef = useRef(0); + const controlsTimeoutRef = useRef(null); + + // Update player state from media status + useEffect(() => { + if (!mediaStatus) { + setPlayerState(DEFAULT_CHROMECAST_STATE); + return; + } + + const streamPosition = (mediaStatus.streamPosition || 0) * 1000; // Convert to ms + const duration = (mediaStatus.mediaInfo?.streamDuration || 0) * 1000; + + setPlayerState((prev) => ({ + ...prev, + isConnected: !!castDevice, + deviceName: castDevice?.deviceName || null, + isPlaying: mediaStatus.playerState === "playing", + isPaused: mediaStatus.playerState === "paused", + isStopped: mediaStatus.playerState === "idle", + isBuffering: mediaStatus.playerState === "buffering", + progress: streamPosition, + duration, + currentItemId: mediaStatus.mediaInfo?.contentId || null, + })); + }, [mediaStatus, castDevice]); + + // Report playback progress to Jellyfin + useEffect(() => { + if ( + !api || + !user?.Id || + !mediaStatus || + !mediaStatus.mediaInfo?.contentId + ) { + return; + } + + const streamPosition = mediaStatus.streamPosition || 0; + + // Report every 10 seconds + if ( + Math.abs(streamPosition - lastReportedProgressRef.current) < + CHROMECAST_CONSTANTS.PROGRESS_REPORT_INTERVAL + ) { + return; + } + + const contentId = mediaStatus.mediaInfo.contentId; + const positionTicks = Math.floor(streamPosition * 10000000); + const isPaused = mediaStatus.playerState === "paused"; + const streamUrl = mediaStatus.mediaInfo.contentUrl || ""; + const isTranscoding = streamUrl.includes("m3u8"); + + getPlaystateApi(api) + .reportPlaybackProgress({ + playbackProgressInfo: { + ItemId: contentId, + PositionTicks: positionTicks, + IsPaused: isPaused, + PlayMethod: isTranscoding ? "Transcode" : "DirectStream", + PlaySessionId: contentId, + }, + }) + .then(() => { + lastReportedProgressRef.current = streamPosition; + }) + .catch((error) => { + console.error("Failed to report Chromecast progress:", error); + }); + }, [ + api, + user?.Id, + mediaStatus?.streamPosition, + mediaStatus?.mediaInfo?.contentId, + mediaStatus?.playerState, + ]); + + // Auto-hide controls + const resetControlsTimeout = useCallback(() => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + + setShowControls(true); + controlsTimeoutRef.current = setTimeout(() => { + setShowControls(false); + }, CHROMECAST_CONSTANTS.CONTROLS_TIMEOUT); + }, []); + + // Playback controls + const play = useCallback(async () => { + await client?.play(); + }, [client]); + + const pause = useCallback(async () => { + await client?.pause(); + }, [client]); + + const stop = useCallback(async () => { + await client?.stop(); + }, [client]); + + const togglePlay = useCallback(async () => { + if (playerState.isPlaying) { + await pause(); + } else { + await play(); + } + resetControlsTimeout(); + }, [playerState.isPlaying, play, pause, resetControlsTimeout]); + + const seek = useCallback( + async (positionMs: number) => { + await client?.seek({ position: positionMs / 1000 }); + resetControlsTimeout(); + }, + [client, resetControlsTimeout], + ); + + const skipForward = useCallback(async () => { + const skipTime = + settings?.forwardSkipTime || CHROMECAST_CONSTANTS.SKIP_FORWARD_TIME; + const newPosition = playerState.progress + skipTime * 1000; + await seek(Math.min(newPosition, playerState.duration)); + }, [ + playerState.progress, + playerState.duration, + seek, + settings?.forwardSkipTime, + ]); + + const skipBackward = useCallback(async () => { + const skipTime = + settings?.rewindSkipTime || CHROMECAST_CONSTANTS.SKIP_BACKWARD_TIME; + const newPosition = playerState.progress - skipTime * 1000; + await seek(Math.max(newPosition, 0)); + }, [playerState.progress, seek, settings?.rewindSkipTime]); + + const disconnect = useCallback(async () => { + await client?.endSession(true); + setPlayerState(DEFAULT_CHROMECAST_STATE); + }, [client]); + + // Time formatting + const currentTime = formatTime(playerState.progress); + const remainingTime = formatTime(playerState.duration - playerState.progress); + const endingTime = calculateEndingTime( + playerState.duration - playerState.progress, + settings?.use24HourFormat ?? true, + ); + + // Next episode countdown + const showNextEpisodeCountdown = shouldShowNextEpisodeCountdown( + playerState.duration - playerState.progress, + !!nextItem, + CHROMECAST_CONSTANTS.NEXT_EPISODE_COUNTDOWN_START, + ); + + // Cleanup + useEffect(() => { + return () => { + if (controlsTimeoutRef.current) { + clearTimeout(controlsTimeoutRef.current); + } + }; + }, []); + + return { + // State + playerState, + showControls, + currentItem, + nextItem, + castDevice, + mediaStatus, + + // Actions + play, + pause, + stop, + togglePlay, + seek, + skipForward, + skipBackward, + disconnect, + setShowControls: resetControlsTimeout, + + // Computed + currentTime, + remainingTime, + endingTime, + showNextEpisodeCountdown, + + // Settings + settings, + }; +}; diff --git a/components/chromecast/hooks/useChromecastSegments.ts b/components/chromecast/hooks/useChromecastSegments.ts new file mode 100644 index 00000000..f697294a --- /dev/null +++ b/components/chromecast/hooks/useChromecastSegments.ts @@ -0,0 +1,166 @@ +/** + * Hook for managing Chromecast segments (intro, credits, recap, commercial, preview) + * Integrates with autoskip branch segment detection + */ + +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { isWithinSegment } from "@/utils/chromecast/helpers"; +import type { ChromecastSegmentData } from "@/utils/chromecast/options"; + +// Placeholder - will integrate with autoskip branch later +interface SegmentData { + introSegments?: Array<{ startTime: number; endTime: number; text: string }>; + creditSegments?: Array<{ startTime: number; endTime: number; text: string }>; + recapSegments?: Array<{ startTime: number; endTime: number; text: string }>; + commercialSegments?: Array<{ + startTime: number; + endTime: number; + text: string; + }>; + previewSegments?: Array<{ startTime: number; endTime: number; text: string }>; +} + +export const useChromecastSegments = ( + item: BaseItemDto | null, + currentProgressMs: number, +) => { + const _api = useAtomValue(apiAtom); + const { settings } = useSettings(); + + // TODO: Replace with actual segment fetching from autoskip branch + // For now, using mock data structure + const segmentData = useMemo(() => { + return { + introSegments: [], + creditSegments: [], + recapSegments: [], + commercialSegments: [], + previewSegments: [], + }; + }, [item?.Id]); + + // Parse segments into usable format + const segments = useMemo(() => { + const intro = + segmentData.introSegments && segmentData.introSegments.length > 0 + ? { + start: segmentData.introSegments[0].startTime, + end: segmentData.introSegments[0].endTime, + } + : null; + + const credits = + segmentData.creditSegments && segmentData.creditSegments.length > 0 + ? { + start: segmentData.creditSegments[0].startTime, + end: segmentData.creditSegments[0].endTime, + } + : null; + + const recap = + segmentData.recapSegments && segmentData.recapSegments.length > 0 + ? { + start: segmentData.recapSegments[0].startTime, + end: segmentData.recapSegments[0].endTime, + } + : null; + + const commercial = (segmentData.commercialSegments || []).map((seg) => ({ + start: seg.startTime, + end: seg.endTime, + })); + + const preview = (segmentData.previewSegments || []).map((seg) => ({ + start: seg.startTime, + end: seg.endTime, + })); + + return { intro, credits, recap, commercial, preview }; + }, [segmentData]); + + // Check which segment we're currently in + const currentSegment = useMemo(() => { + if (isWithinSegment(currentProgressMs, segments.intro)) { + return { type: "intro" as const, segment: segments.intro }; + } + if (isWithinSegment(currentProgressMs, segments.credits)) { + return { type: "credits" as const, segment: segments.credits }; + } + if (isWithinSegment(currentProgressMs, segments.recap)) { + return { type: "recap" as const, segment: segments.recap }; + } + for (const commercial of segments.commercial) { + if (isWithinSegment(currentProgressMs, commercial)) { + return { type: "commercial" as const, segment: commercial }; + } + } + for (const preview of segments.preview) { + if (isWithinSegment(currentProgressMs, preview)) { + return { type: "preview" as const, segment: preview }; + } + } + return null; + }, [currentProgressMs, segments]); + + // Skip functions + const skipIntro = useCallback( + (seekFn: (positionMs: number) => Promise) => { + if (segments.intro) { + return seekFn(segments.intro.end * 1000); + } + }, + [segments.intro], + ); + + const skipCredits = useCallback( + (seekFn: (positionMs: number) => Promise) => { + if (segments.credits) { + return seekFn(segments.credits.end * 1000); + } + }, + [segments.credits], + ); + + const skipSegment = useCallback( + (seekFn: (positionMs: number) => Promise) => { + if (currentSegment) { + return seekFn(currentSegment.segment.end * 1000); + } + }, + [currentSegment], + ); + + // Auto-skip logic based on settings + const shouldAutoSkip = useMemo(() => { + if (!currentSegment) return false; + + switch (currentSegment.type) { + case "intro": + return settings?.autoSkipIntro ?? false; + case "credits": + return settings?.autoSkipCredits ?? false; + case "recap": + case "commercial": + case "preview": + // Add settings for these when available + return false; + default: + return false; + } + }, [currentSegment, settings]); + + return { + segments, + currentSegment, + skipIntro, + skipCredits, + skipSegment, + shouldAutoSkip, + hasIntro: !!segments.intro, + hasCredits: !!segments.credits, + }; +}; diff --git a/utils/chromecast/helpers.ts b/utils/chromecast/helpers.ts new file mode 100644 index 00000000..aecae117 --- /dev/null +++ b/utils/chromecast/helpers.ts @@ -0,0 +1,147 @@ +/** + * 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; + ParentBackdropImageTags?: string[]; + SeriesId?: string; + Id?: string; + }, + 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 new file mode 100644 index 00000000..9c6fd0bf --- /dev/null +++ b/utils/chromecast/options.ts @@ -0,0 +1,70 @@ +/** + * Chromecast player configuration and constants + */ + +export const CHROMECAST_CONSTANTS = { + // Timing + PROGRESS_REPORT_INTERVAL: 10, // seconds + CONTROLS_TIMEOUT: 5000, // ms + BUFFERING_THRESHOLD: 10, // seconds of buffer before hiding indicator + NEXT_EPISODE_COUNTDOWN_START: 30, // seconds before end + CONNECTION_CHECK_INTERVAL: 5000, // ms + + // UI + POSTER_WIDTH: 300, + POSTER_HEIGHT: 450, + MINI_PLAYER_HEIGHT: 80, + SKIP_FORWARD_TIME: 15, // seconds (overridden by settings) + SKIP_BACKWARD_TIME: 15, // seconds (overridden by settings) + + // Animation + ANIMATION_DURATION: 300, // ms + BLUR_RADIUS: 10, +} 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" }, +} as const; + +export type ConnectionQuality = keyof typeof CONNECTION_QUALITY; + +export interface ChromecastPlayerState { + isConnected: boolean; + deviceName: string | null; + isPlaying: boolean; + isPaused: boolean; + isStopped: boolean; + isBuffering: boolean; + progress: number; // milliseconds + duration: number; // milliseconds + volume: number; // 0-1 + isMuted: boolean; + currentItemId: string | null; + connectionQuality: ConnectionQuality; +} + +export interface ChromecastSegmentData { + intro: { start: number; end: number } | null; + credits: { start: number; end: number } | null; + recap: { start: number; end: number } | null; + commercial: { start: number; end: number }[]; + preview: { start: number; end: number }[]; +} + +export const DEFAULT_CHROMECAST_STATE: ChromecastPlayerState = { + isConnected: false, + deviceName: null, + isPlaying: false, + isPaused: false, + isStopped: true, + isBuffering: false, + progress: 0, + duration: 0, + volume: 1, + isMuted: false, + currentItemId: null, + connectionQuality: "EXCELLENT", +};