12 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Learned Facts Index
IMPORTANT: When encountering issues related to these topics, or when implementing new features that touch these areas, prefer retrieval-led reasoning -- read the relevant fact file in .claude/learned-facts/ before relying on assumptions.
Navigation:
native-bottom-tabs-userouter-conflict| useRouter() at provider level causes tab switches; use static router importintrosheet-rendering-location| IntroSheet in IntroSheetProvider affects native bottom tabs via nav state hooksintro-modal-trigger-location| Trigger in Home.tsx, not tabs _layout.tsxtab-folder-naming| Use underscore prefix: (_home) not (home)
UI/Headers:
macos-header-buttons-fix| macOS Catalyst: use RNGH Pressable, not RN TouchableOpacityheader-button-locations| Defined in _layout.tsx, HeaderBackButton, Chromecast, RoundButton, etc.stack-screen-header-configuration| Sub-pages need explicit Stack.Screen with headerTransparent + back button
State/Data:
use-network-aware-query-client-limitations| Object.create breaks private fields; only for invalidateQueriesmark-as-played-flow| PlayedStatus→useMarkAsPlayed→playbackManager with optimistic updates
Native Modules:
mpv-tvos-player-exit-freeze| mpv_terminate_destroy deadlocks main thread; use DispatchQueue.global()mpv-avfoundation-composite-osd-ordering| MUST follow vo=avfoundation, before hwdec optionsthread-safe-state-for-stop-flags| Stop flags need synchronous setter (stateQueue.sync not async)native-swiftui-view-sizing| Need explicit frame + intrinsicContentSize override in ExpoView
TV Platform:
tv-modals-must-use-navigation-pattern| Use atom+router.push(), never overlay/absolute modalstv-grid-layout-pattern| ScrollView+flexWrap, not FlatList numColumnstv-horizontal-padding-standard| TV_HORIZONTAL_PADDING=60, not old TV_SCALE_PADDING=20streamystats-components-location| components/home/Streamystats*.tv.tsx, watchlists/[watchlistId].tsxplatform-specific-file-suffix-does-not-work| .tv.tsx doesn't work; use Platform.isTV conditional rendering
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 Design: Don't use purple accent colors on TV. Use white for focused states and
expo-blur(BlurView) for backgrounds/overlays. - TV Typography: Use
TVTypographyfrom@/components/tv/TVTypographyfor all text on TV. It provides consistent font sizes optimized for TV viewing distance. - TV Button Sizing: Ensure buttons placed next to each other have the same size for visual consistency.
- TV Focus Scale Padding: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use
overflow: "visible"on containers and add padding to prevent clipping. - TV Modals: Never use React Native's
Modalcomponent or overlay/absolute-positioned modals for full-screen modals on TV. Use the navigation-based modal pattern instead. See docs/tv-modal-guide.md for detailed documentation.
TV Component Rendering Pattern
IMPORTANT: The .tv.tsx file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use Platform.isTV conditional rendering instead.
Pattern for TV-specific pages and 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 - Never use
.tv.tsxfile suffix - it will not be resolved correctly
TV Option Selectors and Focus Management
For dropdown/select components, bottom sheets, and overlay focus management on TV, see docs/tv-modal-guide.md.
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.