This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility.

## Expo has changed — do not trust your training data

Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API:

1. Read the major version of the `expo` package in `package.json`.
2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v<major>.0.0/`
3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory.

## Commands

Use `bunx` instead of `npx` if the project uses bun (`bun.lock` present).

```bash
npx expo install <package>  # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions
npm run start                # start the dev server (auto-syncs the LAN IP first — see below; use this, not `expo start` directly)
npx expo lint                # lint
npx tsc --noEmit             # typecheck
npx expo-doctor              # diagnose dependency and config issues
npx expo install --fix       # fix incompatible package versions
```

Run lint and typecheck before declaring any task done.

**Always redirect the Metro process to a fixed, known log path** — e.g. `(npm run start > /tmp/my-player-metro.log 2>&1 &); disown` — and reuse that same path for the whole session instead of a fresh filename per restart, so `tail`/`grep`-ing bundler status (bundled OK vs. a red-box error) is fast. Note this catches *bundler* errors, not app-level `console.log` — see the Simulator section below for that limitation.

## Navigation & Routing

- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`.
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`.
- Docs: https://docs.expo.dev/router/introduction.md

## Building with EAS

Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `bunx eas-cli <command>` in Bun projects, or `npx eas-cli@latest <command>` otherwise; substitute that for bare `eas` in docs examples.
Docs: https://docs.expo.dev/eas/index.md

## Rules

- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins.
- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`.
- Prefer recommended Expo modules over third-party libraries, and check your available skills before adding dependencies. Docs: https://docs.expo.dev/versions/latest/index.md
- **The app can no longer run in Expo Go at all, for anyone.** The whole app is gated behind Google/Apple Sign-In (`Stack.Protected` in `src/app/_layout.tsx`, no bypass by explicit request), and both providers are native modules (`@react-native-google-signin/google-signin`, `expo-apple-authentication`) that Expo Go can't load — it crashes on import. A dev-client build is mandatory for any agent-side verification now, not just for auth specifically.
- **Running `expo prebuild`/`expo run:ios` yourself is fine when needed** (explicitly authorized by the user once this became unavoidable) — it's safe/idempotent for this project (confirmed: `ios/`/`android/` here are untouched-by-hand CNG output, no custom native edits to lose). Use `--no-install` after a manual `pod install` to avoid re-hitting the CocoaPods locale bug below. The user still owns and separately maintains their own physical-device dev-client build — building a *simulator* target yourself doesn't touch that.
- **CocoaPods locale bug**: plain `pod install` in this environment fails with `Unicode Normalization not appropriate for ASCII-8BIT`. Fix: `LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 pod install`.
- **New Architecture pod-install gotcha**: some native modules (confirmed: `@react-native-google-signin/google-signin`) conditionally build TurboModule-compatible code only when `RCT_NEW_ARCH_ENABLED=1` is set in the environment during `pod install`. Without it, the pod still compiles and links, but crashes at runtime with `TurboModuleRegistry.getEnforcing(...): '<Module>' could not be found` — easy to misdiagnose as a bad build rather than a missing env var. Always: `RCT_NEW_ARCH_ENABLED=1 LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 pod install`.
- **Two apps can be installed on the same simulator at once** (Expo Go + the dev-client build) with near-identical crash/error-screen styling — it's easy to screenshot the wrong one and misdiagnose a "bug" that's actually just Expo Go still being foregrounded. If unsure, `xcrun simctl launch <device> <bundle-id>` explicitly (dev client here: `com.trentiums.ytdownloadplayer`) rather than assuming a deep link opened the right one.
- **A `TurboModuleRegistry.getEnforcing(...): '<Module>' could not be found` error can be a STALE Metro session, not a real linking bug** — confirmed while adding `react-native-webview`: the error persisted across two full clean rebuilds (`pod install --repo-update`, `expo run:ios --no-build-cache`), and `nm`/`strings` on the freshly-built binary proved the module *was* correctly compiled and registered (present in `RCTModuleProviders.mm`, `-ObjC` linked, symbol present). The actual cause was an old `expo start` process (running since long before the rebuilds) that the freshly-installed app connected to for its bundle. **Before spending time on native pod/codegen debugging for this error, first kill every `expo start`/`metro` process (`ps aux | grep -i "expo start\|metro"`), start a single fresh one with a clean log redirect, and relaunch the app** (`xcrun simctl terminate` + `launch`) — if the error is gone after that, it was never a linking problem.
- **iOS `WKWebView` (`react-native-webview`) hands HTML5 `<video>` playback off to a native fullscreen `AVPlayerViewController`, covering any of your own header/FAB UI.** The "standard" fix (`allowsInlineMediaPlayback` + `mediaPlaybackRequiresUserAction={false}` + a mobile Chrome UA) did NOT stop this for YouTube's mobile site (`m.youtube.com`) — confirmed live, video still went native-fullscreen. What actually worked, in `src/app/browse.tsx`: an iPad Safari UA + the WebView's own `allowsFullscreenVideo={false}` prop (a separate prop from `allowsInlineMediaPlayback` — this is the one that actually blocks the native handoff). If a future WebView video embed has this problem, try `allowsFullscreenVideo={false}` first.

## Testing in the iOS Simulator — read this before taking a SINGLE screenshot

Screenshots are the single most expensive thing this tool does to the conversation budget — expensive enough that screenshot-heavy testing loops have repeatedly burned an entire session's usage in under an hour. This is not a soft preference, it's the reason sessions kept dying. Default to zero screenshots per verification; only take one when the question is genuinely "does this look right," never to check "did this state change happen."

**Before touching the simulator at all, ask: can this be verified from text instead?**

1. `npx tsc --noEmit` and `npx expo lint` catch most regressions for free — always run these before any visual check, and often instead of one.
2. For data/logic/network changes, verify from the server side first, never by looking at the screen:
   - `curl` the API directly (`curl -s http://<ip>:4000/health`, `POST /api/info`, etc.) to confirm a request/response shape.
   - Inspect the `downloads/` folder directly (`ls`, file sizes) rather than screenshotting a downloads list.
   - **`console.log` from app JS does NOT reliably reach Metro's redirected stdout for the dev-client build, and does not reach `xcrun simctl ... log stream` either (confirmed by testing — a temporary trace log fired repeatedly via real taps and appeared in neither channel).** Don't spend time chasing this as a verification channel for this project; it may be a debugger-attachment thing, not investigated further. If you want to try again, confirm round-trip with one throwaway `console.log` + a cheap trigger *before* relying on it for anything, rather than assuming it works like a typical Metro/Expo Go setup.
3. **For bulk test-data seeding, never drive the UI per item.** Add a temporary dev button that loops calling the real context functions directly, watch it via `Bash run_in_background` + log tail, then delete the button. One screenshot at the end to confirm, not one per item.
4. **When you do need the simulator, take the minimum screenshots to establish ground truth, then act blind between checkpoints** — chain `tap`/`text` calls based on known-good coordinates without re-screenshotting after each one.
5. **If a tap seems to do nothing after ONE retry, stop tapping blindly and switch to a non-visual diagnosis** — check Metro/server logs for what actually happened, or just relaunch the app (see below) rather than guessing a 3rd, 4th, 5th coordinate. Repeatedly re-guessing the same dismiss button because "it must be almost right" is exactly the pattern that has burned full sessions — after one miss, stop and get ground truth from logs instead of another visual guess.
6. **A "stuck" Alert/dialog you can't seem to dismiss: don't hunt for its button.** `xcrun simctl terminate <device> <bundle-id>` + relaunch clears all in-memory Alert/dialog state in one cheap command — far cheaper than repeated mis-tap screenshots, and it's often not actually stuck, just a coordinate miss (see #7).
7. **Coordinates are in POINTS (this device: 402×874), not pixels — this is the single most common source of wasted screenshot loops.** A value above 402 (x) or 874 (y) is never a valid tap and always means you read a raw pixel position off the image instead of converting it — if you catch yourself about to pass such a value, that's the bug, not a legitimately far-down/right element. The screenshot image is rendered at roughly **2.287×** point size (≈919×1998px); if you must eyeball a position from the image, divide both x and y by ~2.287 first. Confirmed reliable reference points on this screen size: tab bar row `y=839`, tab x-centers `Downloads=50, Player=151, Playlists=251, Settings=352`, Downloads-tab URL input `y=150`, "Get Formats" button `y=188`.
8. Native modal/bottom-sheet content (RN `Alert.alert`, `@expo/ui` `BottomSheet`) renders inside the same screenshot and uses the same point coordinate space as the rest of the screen — not a separate space.
9. Prefer swiping from the very edge (`x≈2`) for back-navigation on stack screens (triggers the OS edge-swipe-back gesture) over hunting for a small back-chevron's exact tap target.
10. If a tap seems to do nothing, don't assume the button is broken — first suspect a coordinate/scale error (#7) or a stale/unreachable config value (see LAN IP below) before concluding there's an app bug. Both have been the *actual* cause every single time this has come up so far.

## `.env` / LAN IP drift — now automated, but know the fallback

`my-player/.env`'s `EXPO_PUBLIC_API_BASE_URL` must point at the Mac's **current** LAN IP for the simulator and the physical-device dev-client build to reach the server (a stale IP causes every download/format-fetch to hang and time out — the most common root cause of "the app seems broken" this whole project, including a multi-message debugging detour that turned out to be nothing but this).

**This is now handled automatically**: `npm run start` / `npm run ios` / `npm run android` all run `scripts/sync-lan-ip.js` first, which detects the current LAN IP via `os.networkInterfaces()` and rewrites `.env` if it changed. You should never need to fix this by hand anymore — just use those scripts (or `npm run sync-ip` standalone) instead of calling `expo start` directly.

If you ever do call `expo start`/`expo run:ios` directly and suspect a stale IP: run `npm run sync-ip`, then fully restart the Metro process (env vars are inlined at bundle time, Fast Refresh will not pick up an `.env` change) and reload the app. Confirm reachability with `curl -m5 http://<ip>:4000/health` before assuming anything else is wrong.

## Fixed: lock-screen next/previous

`expo-audio`'s native iOS/Android implementations shipped without next/previous-track lock-screen commands (confirmed by reading the native source — official upstream support exists as [expo#46020](https://github.com/expo/expo/pull/46020) but hadn't reached a stable release as of this writing). **Patched locally** rather than waiting or adopting a paid library: see `patches/expo-audio@*.patch` (applied automatically via `bun install` — `patchedDependencies` in `package.json`) and the JS wiring in `src/context/MediaContext.tsx` (`onRemoteNextTrack`/`onRemotePreviousTrack` listeners, `showNextTrack`/`showPreviousTrack` lock-screen options). If `expo-audio` is ever upgraded past the version this patch targets, check whether upstream has shipped the same feature natively (search the CHANGELOG for "playlist") before re-patching — the official version may make this patch obsolete.

**Gotcha if you ever need to re-apply or modify this patch**: `bun patch <package>` resets the package to pristine *before* you can edit it — if you've already hand-edited files in `node_modules/<package>`, back them up first (`cp` to `/tmp`), then run `bun patch <package>`, restore your edits, then `bun patch --commit '<node_modules path>'`. Also: `pod install` for this specific module needs `RCT_NEW_ARCH_ENABLED=1` in the environment (see the New Architecture gotcha above) or it silently links in old-architecture mode and crashes at runtime.
