Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,29 @@ npm run lint && npm run typecheck && npm test
cd android && ./gradlew :audio-tags:testDebugUnitTest
```

All four must be green — currently **292 tests / 20 suites**, working tree clean.
All four must be green — currently **335 tests / 24 suites**, working tree clean.
Do not build on red.

## Do NOT branch from `main`
`npm test` needs watchman to be usable. Where it is not, `npx jest --watchman=false`
is the same run.

`main` contains **3 files** (AGENTS.md and two docs). It is not the project. All
225 files live on `fix/performance-ux-stats`, and `main` is an ancestor of it.
Two previous sessions were told "the user merged to main, branch from there" —
it was not true either time, and branching from `main` would discard everything.
Stay on `fix/performance-ux-stats` unless `git ls-tree -r --name-only main | wc -l`
says otherwise.
## Check what `main` holds before branching from it

This section used to say `main` contained three files and must never be branched
from. **That is no longer true**: PR #2 merged the project into `main` on
2026-08-02, and `main` is now the whole app.

It is still worth checking rather than assuming, in either direction. As of
2026-08-02 the newest work sits *ahead* of `main` on `fix/ux-round-2` — four
commits including `listenCycle.ts`, migration 0003 and the removal of
`app/player.tsx` — so a branch taken from `main` on that date would have
silently dropped them. `fix/final-polish` was therefore taken from
`fix/ux-round-2`, which is `main` plus those four.

```bash
git log --oneline main..HEAD # what would be lost by branching from main
git diff --stat main..HEAD | tail -1
```

## State

Expand Down
14 changes: 10 additions & 4 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { Tabs } from 'expo-router';
import { BottomTabBar } from 'expo-router/build/react-navigation/bottom-tabs';
import type { BottomTabBarProps } from 'expo-router/build/react-navigation/bottom-tabs';
import { BarChart3, Disc3, ListMusic, SlidersHorizontal } from 'lucide-react-native';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { View } from 'react-native';
import { type LayoutChangeEvent, View } from 'react-native';

import { Toaster } from '@/components/ui/Toaster';
import { MiniPlayer } from '@/features/player/components/MiniPlayer';
import { setPlayerTabBarHeight } from '@/features/player/playerLayerLayout';
import * as perf from '@/services/perf';
import { useLifecycleTrace } from '@/services/perf/useLifecycleTrace';
import { useTheme } from '@/theme/useTheme';
Expand All @@ -23,8 +24,14 @@ import { useTheme } from '@/theme/useTheme';
*/
function TabBarWithPlayer(props: BottomTabBarProps) {
useLifecycleTrace('TabBar');
const onLayout = useCallback((event: LayoutChangeEvent) => {
setPlayerTabBarHeight(event.nativeEvent.layout.height);
}, []);

useEffect(() => () => setPlayerTabBarHeight(0), []);

return (
<View>
<View onLayout={onLayout}>
{/*
Toasts stack directly on top of the transport, which is why they live
here rather than at the root. Positioning them from the root would mean
Expand All @@ -35,7 +42,6 @@ function TabBarWithPlayer(props: BottomTabBarProps) {
Stacking solves it with neither.
*/}
<Toaster />
<MiniPlayer />
<BottomTabBar {...props} />
</View>
);
Expand Down
20 changes: 12 additions & 8 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useEffect } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';

import { useDatabase } from '@/db/useDatabase';
import { PlayerLayer } from '@/features/player/PlayerLayer';
import { startListenRecording } from '@/features/player/listenRecorder';
import { initI18n } from '@/i18n';
import { APP_FONTS } from '@/theme/fonts';
Expand Down Expand Up @@ -56,14 +57,17 @@ export default function RootLayout() {
// one, and gesture-handler throws rather than silently ignoring gestures.
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
{/* Now Playing is somewhere you go from a track and dismiss, not a
destination you switch to — so it presents rather than pushes. */}
<Stack.Screen name="player" options={{ presentation: 'modal' }} />
<Stack.Screen name="queue" options={{ presentation: 'modal' }} />
</Stack>

<PlayerLayer>
{/*
No queue route. It was a modal here and it never appeared: PlayerLayer
mounts Now Playing outside the navigator, and an opaque full-screen
overlay covers whatever the navigator puts under it. The queue is a
root-level sheet now — see `QueueOverlay`.
*/}
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
</Stack>
</PlayerLayer>
</GestureHandlerRootView>
);
}
5 changes: 0 additions & 5 deletions app/player.tsx

This file was deleted.

5 changes: 0 additions & 5 deletions app/queue.tsx

This file was deleted.

20 changes: 20 additions & 0 deletions assets.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Image imports.
*
* Neither `expo/types` nor React Native 0.86 declares these — `expo/types`
* covers CSS and nothing else, and the image declarations that used to come
* from `@types/react-native` went away with that package. So importing a PNG
* is a type error until something says what one is.
*
* On native, Metro replaces the import with an asset-registry id, which is a
* number. That is what `expo-asset`'s `Asset.fromModule` takes.
*/
declare module '*.png' {
const asset: number;
export default asset;
}

declare module '*.jpg' {
const asset: number;
export default asset;
}
Binary file added assets/images/notification-artwork.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/01-TECH-STACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ These are the four choices that shape everything else. Read §2 before locking t
|---|---|---|
| Framework | **Expo SDK 57** (RN 0.86, React 19.2) | SDK 56 (RN 0.85) is the conservative pick. SDK 55+ is New-Architecture-only. |
| Language | **TypeScript**, `strict: true` | No `any` in committed code. |
| Routing | **expo-router** (file-based) | Tabs + a modal route for Now Playing. |
| Routing | **expo-router** (file-based) | Tabs and detail routes; Now Playing is a root overlay. |
| Audio | **`expo-audio`** | Background playback, media notification, lock-screen controls, playlists. Media3/ExoPlayer on Android → native FLAC, ALAC, Opus, Vorbis, WAV support. |
| Styling | **NativeWind** (Tailwind for RN) | Requested. Dark mode via `dark:` variant. |
| Database | **`expo-sqlite`** + **Drizzle ORM** | Typed schema, `drizzle-kit` migrations, `useLiveQuery` for reactive lists. |
Expand Down Expand Up @@ -127,7 +127,6 @@ app/ # expo-router routes ONLY — thin, no logic
playlists.tsx
stats.tsx
settings.tsx
player.tsx # Now Playing (modal)
playlist/[id].tsx
album/[id].tsx
artist/[id].tsx
Expand All @@ -140,6 +139,7 @@ src/
stats/ # StatCard, TopList, PeriodPicker, WrappedCard
features/ # feature-scoped hooks + orchestration
library/ player/ playlists/ stats/ settings/ scanner/
player/PlayerLayer.tsx # root Now Playing overlay, outside routes
db/
client.ts # openDatabaseSync + pragmas
schema.ts # Drizzle schema
Expand Down
7 changes: 5 additions & 2 deletions docs/adr/006-manual-add-is-first-class.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# 006 — Manual adding is a first-class entry point

> **Partly superseded by ADR 010.** Folder import remains a first-class path,
> but the automatic launch sweep was removed. Every scan is now user-initiated.

## Context

The original Phase 2 plan had automatic MediaStore scanning as the way music
Expand All @@ -24,8 +27,8 @@ Two equal entry points into one pipeline.
action inside the empty state. It opens the system folder picker, records the
chosen tree URI in `scan_folders`, and runs the same scan.

The automatic MediaStore sweep still runs in the background without the user
asking, because for the common case it costs nothing and needs no interaction.
The library scan is explicit as well. The user starts it from the permanent
Scan action; the common case stays one action without hidden work at launch.

Both go through `enumerateLibrary` then `enrichLibrary`, write through the same
queries, and report the same `ScanProgress`. There is no second code path to
Expand Down
14 changes: 8 additions & 6 deletions docs/adr/007-saf-folders-go-through-mediastore.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,18 @@ handles it with no second code path.
The second also fixes a separate problem the tree walk would not have touched:
a file copied over USB is frequently not in MediaStore for minutes, because
nothing has told the scanner it exists. That is not a "manual add" case at all
— it is the ordinary automatic scan appearing to lose files.
— it is the ordinary library scan appearing to lose files.

## Decision

**Trigger a media scan; do not walk the tree.**

`requestMediaScan(paths)` wraps `MediaScannerConnection.scanFile()` and
resolves once the scanner has visited every path. `addFolder` calls it for the
picked folder, then runs the normal two-stage scan. `tracks.file_uri` stays a
single kind of URI throughout the app.
resolves once the scanner has visited every path. `importFolder` calls it for
the picked folder, then runs the normal two-stage scan. `tracks.file_uri` stays
a single kind of URI throughout the app. The library first shows a clear
confirmation, then a full-screen cancellable progress state until both stages
finish.

The same call also backs the manual **rescan** affordance, so a user who has
just copied files in can pull to refresh and see them without restarting the
Expand All @@ -53,14 +55,14 @@ scan reads MediaStore, not the folder list. They exist so a future rescan can
re-index the same folders, and so Library settings can show what was added.

Converting a SAF tree URI to filesystem paths is not always possible on modern
Android. Where it fails, `addFolder` still runs the normal sweep — the user
Android. Where it fails, `importFolder` still runs the normal sweep — the user
gets whatever MediaStore already knows, rather than an error for something they
cannot act on.

`requestMediaScan` no longer resolves strictly on the last callback. The
callback is not guaranteed to fire once per path — a path that does not exist,
or a directory the provider declines to walk, can be dropped — and a dropped
one left the promise unsettled forever. Since `addFolder` awaits it *before*
one left the promise unsettled forever. Since `importFolder` awaits it *before*
starting the scan, that was a frozen screen with no error state to show. It now
settles on whatever has arrived after ten seconds.

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/008-permission-is-asked-not-assumed.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ is a button that silently does nothing. `permissionErrorFor` maps the answer to
the error code the screen renders and is unit tested, per the rule that logic
belongs in `src/services/` rather than in a hook body.

`addFolder` asks **before** opening the picker. Asking afterwards means a user
`pickFolder` asks **before** opening the picker. Asking afterwards means a user
who declines has chosen a folder for nothing.

## Consequences
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/009-expo-audio-and-our-own-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ repeat-one meeting an explicit skip — is a unit test that runs on a laptop.
repeat-one repeats; a track the user skips advances. Same mode, different
input, and treating them identically makes the button look broken.

**The three-second rule lives in the engine, not the queue.** Pressing previous
more than three seconds in restarts the current track. That needs the playback
**The ten-second rule lives in the engine, not the queue.** Pressing previous
at or beyond ten seconds restarts the current track. That needs the playback
position, which the queue does not have and should not.

## Consequences
Expand Down
70 changes: 70 additions & 0 deletions docs/adr/014-queue-is-a-root-sheet-not-a-route.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 014 — The queue is a root-level sheet, not a route

## Context

The queue was `app/queue.tsx`, declared in the root `Stack` with
`presentation: 'modal'` and opened with `router.navigate('/queue')`. Pressing
the queue button did open it. Nothing was ever visible.

The cause is not in `QueueScreen`. `PlayerLayer` mounts Now Playing **outside
the navigator** — an absolutely positioned, full-screen, opaque surface over
every route, which is what lets the mini player and the expanded player share
one gesture progress value and what keeps playback controls above every screen.
An overlay at that level covers whatever the navigator puts underneath it. The
queue was rendering correctly, one layer down, behind the player that opened
it.

That is worth stating in its general form, because it is not a fact about the
queue: **any route pushed while Now Playing is open disappears the same way.**
The overlay is a second, higher stacking context that expo-router does not know
about.

There is a second, independent reason a route cannot work here. The overlay
carries a `translateY` transform and clips its contents, and a transformed
ancestor creates a containing block — so even a surface that won the z-order
would be positioned and clipped relative to the overlay rather than the window.

## Decision

**The queue is a sibling of Now Playing at the root, owned by `PlayerLayer`,
one layer above it.** `QueueOverlay` renders it; `PlayerLayer` holds the open
state; `PlayerScreen` receives `onOpenQueue` as a prop. The route and its
`Stack.Screen` entry are deleted.

This is the same treatment Now Playing itself already gets, and for the same
reason: it is a surface belonging to the player, not a destination in the app's
navigation. It also matches how it behaves — the queue is opened from Now
Playing and dismissed back to it, never navigated *through*.

The alternative considered was collapsing Now Playing before navigating, so the
modal had nothing above it. It works, and it is wrong: dismissing the queue
would then return to the tab the user came from rather than to the player they
opened it from, and the queue would visibly close the player to open itself.

## Consequences

`QueueScreen` takes an `onClose` prop instead of calling `router.back()`. It is
mounted only while open — it carries a FlashList over the whole queue, and the
player should not pay for that while nobody is looking at it.

The sheet animates with Reanimated's `SlideInDown`/`SlideOutDown` layout
animations rather than a shared value, which keeps it clear of the React
Compiler's immutability rule about shared values captured by hooks — the reason
`playerExpansion` is a module-level `makeMutable` and the reason gestures in
this feature are built inline.

Deep-linking to the queue is gone. Nothing linked to it, `mufify://queue` was
never documented, and a queue is transient state rather than an addressable
place.

**Anything else that needs to appear over Now Playing has to go here too.** A
future "add to playlist" sheet opened from the player cannot be a route. That
is the standing cost of a root-mounted player overlay, and it is a cost this
project already accepted deliberately — see `docs/player.md` on why playback
outlives every screen.

## References

- `src/features/player/PlayerLayer.tsx` — the root layer and its stacking order.
- `src/features/player/components/QueueOverlay.tsx` — the sheet.
- `docs/components.md` — the component tree.
Loading