Files
streamyfin/CLAUDE.md
Fredrik Burmester 85a74a9a6a refactor: login page
2026-01-31 10:52:21 +01:00

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 import
  • introsheet-rendering-location | IntroSheet in IntroSheetProvider affects native bottom tabs via nav state hooks
  • intro-modal-trigger-location | Trigger in Home.tsx, not tabs _layout.tsx
  • tab-folder-naming | Use underscore prefix: (_home) not (home)

UI/Headers:

  • macos-header-buttons-fix | macOS Catalyst: use RNGH Pressable, not RN TouchableOpacity
  • header-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 invalidateQueries
  • mark-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 options
  • thread-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 modals
  • tv-grid-layout-pattern | ScrollView+flexWrap, not FlatList numColumns
  • tv-horizontal-padding-standard | TV_HORIZONTAL_PADDING=60, not old TV_SCALE_PADDING=20
  • streamystats-components-location | components/home/Streamystats*.tv.tsx, watchlists/[watchlistId].tsx
  • platform-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 routing
  • components/ - Reusable UI components
  • providers/ - React Context providers
  • hooks/ - Custom React hooks
  • utils/ - Utilities including Jotai atoms
  • modules/ - Native modules (vlc-player, mpv-player, background-downloader)
  • translations/ - i18n translation files

Key Patterns

State Management:

  • Global state uses Jotai atoms in utils/atoms/
  • settingsAtom in utils/atoms/settings.ts for app settings
  • apiAtom and userAtom in providers/JellyfinProvider.tsx for auth state
  • Server state uses React Query with @tanstack/react-query

Jellyfin API Access:

  • Use apiAtom from JellyfinProvider for 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 useAppRouter from @/hooks/useAppRouter instead of useRouter from expo-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 OfflineModeProvider from @/providers/OfflineModeProvider to wrap pages that support offline content
  • Use useOfflineMode() hook to check if current context is offline
  • The useAppRouter hook automatically injects offline=true param when navigating within an offline context

Providers (wrapping order in app/_layout.tsx):

  1. JotaiProvider
  2. QueryClientProvider
  3. JellyfinProvider (auth, API)
  4. NetworkStatusProvider
  5. PlaySettingsProvider
  6. WebSocketProvider
  7. DownloadProvider
  8. MusicPlayerProvider

Native Modules

Located in modules/:

  • vlc-player - VLC video player integration
  • mpv-player - MPV video player integration (iOS)
  • background-downloader - Background download functionality
  • sf-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 :tv suffix 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 TVTypography from @/components/tv/TVTypography for 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 Modal component 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.tsx and TVMyComponent.tsx)
  • Use Platform.isTV to conditionally render the appropriate component
  • TV components typically use TVInput, TVServerCard, and other TV-prefixed components with focus handling
  • Never use .tv.tsx file 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:

  1. Use FlatList instead of FlashList for TV - FlashList has known focus issues on TV platforms. Use regular FlatList with Platform.isTV check:
{Platform.isTV ? (
  <FlatList
    data={items}
    renderItem={renderTVItem}
    removeClippedSubviews={false}
    // ...
  />
) : (
  <FlashList data={items} renderItem={renderItem} />
)}
  1. Add removeClippedSubviews={false} - Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements.

  2. 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} />
  1. 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>
  1. 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.