12 KiB
CLAUDE.md
@.claude/learned-facts.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
Development Commands
CRITICAL: Always use bun for package management. Never use npm, yarn, or npx.
# Setup
bun i && bun run submodule-reload
# Development builds
bun run prebuild # Mobile prebuild
bun run ios # Run iOS
bun run android # Run Android
# TV builds (suffix with :tv)
bun run prebuild:tv
bun run ios:tv
bun run android:tv
# Code quality
bun run typecheck # TypeScript check
bun run check # BiomeJS check
bun run lint # BiomeJS lint + fix
bun run format # BiomeJS format
bun run test # Run all checks (typecheck, lint, format, doctor)
# iOS-specific
bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build errors
Tech Stack
- Runtime: Bun
- Framework: React Native (Expo SDK 54)
- Language: TypeScript (strict mode)
- State Management: Jotai (global state atoms) + React Query (server state)
- API: Jellyfin SDK (
@jellyfin/sdk) - Navigation: Expo Router (file-based)
- Linting/Formatting: BiomeJS
- Storage: react-native-mmkv
Architecture
File Structure
app/- Expo Router screens with file-based routingcomponents/- Reusable UI componentsproviders/- React Context providershooks/- Custom React hooksutils/- Utilities including Jotai atomsmodules/- Native modules (vlc-player, mpv-player, background-downloader)translations/- i18n translation files
Key Patterns
State Management:
- Global state uses Jotai atoms in
utils/atoms/ settingsAtominutils/atoms/settings.tsfor app settingsapiAtomanduserAtominproviders/JellyfinProvider.tsxfor auth state- Server state uses React Query with
@tanstack/react-query
Jellyfin API Access:
- Use
apiAtomfromJellyfinProviderfor authenticated API calls - Access user via
userAtom - Use Jellyfin SDK utilities from
@jellyfin/sdk/lib/utils/api
Navigation:
- File-based routing in
app/directory - Tab navigation:
(home),(search),(favorites),(libraries),(watchlists) - Shared routes use parenthesized groups like
(home,libraries,search,favorites,watchlists) - IMPORTANT: Always use
useAppRouterfrom@/hooks/useAppRouterinstead ofuseRouterfromexpo-router. This custom hook automatically handles offline mode state preservation across navigation:// ✅ Correct import useRouter from "@/hooks/useAppRouter"; const router = useRouter(); // ❌ Never use this import { useRouter } from "expo-router"; import { router } from "expo-router";
Offline Mode:
- Use
OfflineModeProviderfrom@/providers/OfflineModeProviderto wrap pages that support offline content - Use
useOfflineMode()hook to check if current context is offline - The
useAppRouterhook automatically injectsoffline=trueparam when navigating within an offline context
Providers (wrapping order in app/_layout.tsx):
- JotaiProvider
- QueryClientProvider
- JellyfinProvider (auth, API)
- NetworkStatusProvider
- PlaySettingsProvider
- WebSocketProvider
- DownloadProvider
- MusicPlayerProvider
Native Modules
Located in modules/:
vlc-player- VLC video player integrationmpv-player- MPV video player integration (iOS)background-downloader- Background download functionalitysf-player- Swift player module
Path Aliases
Use @/ prefix for imports (configured in tsconfig.json):
import { useSettings } from "@/utils/atoms/settings";
import { apiAtom } from "@/providers/JellyfinProvider";
Coding Standards
- Use TypeScript for all files (no .js)
- Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
- Handle both mobile and TV navigation patterns
- Use existing atoms, hooks, and utilities before creating new ones
- Use Conventional Commits:
feat(scope):,fix(scope):,chore(scope):
Platform Considerations
- TV version uses
:tvsuffix for scripts - Platform checks:
Platform.isTV,Platform.OS === "android"or"ios" - Some features disabled on TV (e.g., notifications, Chromecast)
TV Component Rendering Pattern
IMPORTANT: The .tv.tsx file suffix only works for pages in the app/ directory (resolved by Expo Router). It does NOT work for components - Metro bundler doesn't resolve platform-specific suffixes for component imports.
Pattern for TV-specific components:
// In page file (e.g., app/login.tsx)
import { Platform } from "react-native";
import { Login } from "@/components/login/Login";
import { TVLogin } from "@/components/login/TVLogin";
const LoginPage: React.FC = () => {
if (Platform.isTV) {
return <TVLogin />;
}
return <Login />;
};
export default LoginPage;
- Create separate component files for mobile and TV (e.g.,
MyComponent.tsxandTVMyComponent.tsx) - Use
Platform.isTVto conditionally render the appropriate component - TV components typically use
TVInput,TVServerCard, and other TV-prefixed components with focus handling
TV Option Selector Pattern (Dropdowns/Multi-select)
For dropdown/select components on TV, use a bottom sheet with horizontal scrolling. This pattern is ideal for TV because:
- Horizontal scrolling is natural for TV remotes (left/right D-pad)
- Bottom sheet takes minimal screen space
- Focus-based navigation works reliably
Key implementation details:
- Use absolute positioning instead of Modal - React Native's
Modalbreaks the TV focus chain. Use an absolutely positionedViewoverlay instead:
<View style={{
position: "absolute",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}>
<BlurView intensity={80} tint="dark" style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24 }}>
{/* Content */}
</BlurView>
</View>
- Horizontal ScrollView with focusable cards:
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ paddingHorizontal: 48, paddingVertical: 10, gap: 12 }}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
hasTVPreferredFocus={index === selectedIndex}
onPress={() => { onSelect(option.value); onClose(); }}
// ...
/>
))}
</ScrollView>
- Focus handling on cards - Use
PressablewithonFocus/onBlurandhasTVPreferredFocus:
<Pressable
onPress={onPress}
onFocus={() => { setFocused(true); animateTo(1.05); }}
onBlur={() => { setFocused(false); animateTo(1); }}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View style={{ transform: [{ scale }], backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)" }}>
<Text style={{ color: focused ? "#000" : "#fff" }}>{label}</Text>
</Animated.View>
</Pressable>
- Add padding for scale animations - When items scale on focus, add enough padding (
overflow: "visible"+paddingVertical) so scaled items don't clip.
Reference implementation: See TVOptionSelector and TVOptionCard in components/ItemContent.tv.tsx
TV Focus Management for Overlays/Modals
CRITICAL: When displaying overlays (bottom sheets, modals, dialogs) on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop that freezes navigation.
Solution: Add a disabled prop to every focusable component and pass disabled={isModalOpen} when an overlay is visible:
// 1. Track modal state
const [openModal, setOpenModal] = useState<ModalType | null>(null);
const isModalOpen = openModal !== null;
// 2. Each focusable component accepts disabled prop
const TVFocusableButton: React.FC<{
onPress: () => void;
disabled?: boolean;
}> = ({ onPress, disabled }) => (
<Pressable
onPress={onPress}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={isFirst && !disabled}
>
{/* content */}
</Pressable>
);
// 3. Pass disabled to all background components when modal is open
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
Reference implementation: See settings.tv.tsx for complete example with TVSettingsOptionButton, TVSettingsToggle, TVSettingsStepper, etc.
TV Focus Flickering Between Zones (Lists with Headers)
When you have a page with multiple focusable zones (e.g., a filter bar above a grid), the TV focus engine can rapidly flicker between elements when navigating between zones. This is a known issue with React Native TV.
Solutions:
- Use FlatList instead of FlashList for TV - FlashList has known focus issues on TV platforms. Use regular FlatList with
Platform.isTVcheck:
{Platform.isTV ? (
<FlatList
data={items}
renderItem={renderTVItem}
removeClippedSubviews={false}
// ...
/>
) : (
<FlashList data={items} renderItem={renderItem} />
)}
-
Add
removeClippedSubviews={false}- Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements. -
Only ONE element should have
hasTVPreferredFocus- Never have multiple elements competing for initial focus. Choose one element (usually the first filter button or first list item) to have preferred focus:
// ✅ Good - only first filter button has preferred focus
<TVFilterButton hasTVPreferredFocus={index === 0} />
<TVFocusablePoster /> // No hasTVPreferredFocus
// ❌ Bad - both compete for focus
<TVFilterButton hasTVPreferredFocus />
<TVFocusablePoster hasTVPreferredFocus={index === 0} />
- Keep headers/filter bars outside the list - Instead of using
ListHeaderComponent, render the filter bar as a separate View above the FlatList:
<View style={{ flex: 1 }}>
{/* Filter bar - separate from list */}
<View style={{ flexDirection: "row", gap: 12 }}>
<TVFilterButton />
<TVFilterButton />
</View>
{/* Grid */}
<FlatList data={items} renderItem={renderTVItem} />
</View>
- Avoid multiple scrollable containers - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers.
Reference implementation: See app/(auth)/(tabs)/(libraries)/[libraryId].tsx for the TV filter bar + grid pattern.
TV Focus Guide Navigation (Non-Adjacent Sections)
When you need focus to navigate between sections that aren't geometrically aligned (e.g., left-aligned buttons to a horizontal ScrollView), use TVFocusGuideView with the destinations prop:
// 1. Track destination with useState (NOT useRef - won't trigger re-renders)
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
// 2. Place invisible focus guide between sections
{firstCardRef && (
<TVFocusGuideView
destinations={[firstCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
// 3. Target component must use forwardRef
const MyCard = React.forwardRef<View, Props>(({ ... }, ref) => (
<Pressable ref={ref} ...>
...
</Pressable>
));
// 4. Pass state setter as callback ref to first item
{items.map((item, index) => (
<MyCard
ref={index === 0 ? setFirstCardRef : undefined}
...
/>
))}
For detailed documentation and bidirectional navigation patterns, see docs/tv-focus-guide.md
Reference implementation: See components/ItemContent.tv.tsx for bidirectional focus navigation between playback options and cast list.