Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d5b9441
perf(scanner): make stage one set-based so the scan stops freezing th…
Yefee8 Aug 1, 2026
ac0614f
feat(scanner): scanning is something you press, not something that ha…
Yefee8 Aug 1, 2026
46d07a3
perf(library): stop every visible row re-rendering on each checkbox tap
Yefee8 Aug 1, 2026
bdb99c0
fix(library): show a skeleton during import, not an invitation to import
Yefee8 Aug 1, 2026
a89de3d
feat(player): a real carousel, a working swipe-up, and a previous button
Yefee8 Aug 1, 2026
3e48a19
feat(settings): say what each setting does
Yefee8 Aug 1, 2026
00fe1b8
fix(stats): attribute a listen to the queue it came from
Yefee8 Aug 1, 2026
3a96ed9
feat(stats): count a repeated track as more than one listen
Yefee8 Aug 1, 2026
c0ca6de
feat(stats): show listening time, add albums and playlists, and a Wra…
Yefee8 Aug 1, 2026
4432bb8
feat(library): artist and album shelves
Yefee8 Aug 1, 2026
ee1bb5c
docs(performance): record the transition, modal and list findings
Yefee8 Aug 1, 2026
7ff1fb0
docs(performance): record the regression pass
Yefee8 Aug 1, 2026
3dcac80
docs: README, CONTRIBUTING, components.md, LICENSE, and a launcher
Yefee8 Aug 1, 2026
a90e41a
chore(perf): quieten the instrumentation so the real device can be me…
Yefee8 Aug 1, 2026
b0e0a1b
docs: add HANDOFF.md for the next session
Yefee8 Aug 1, 2026
255cacc
fix(docs): close the codec finding — it was never a defect
Yefee8 Aug 1, 2026
988a7c7
docs: add architecture.md, the map the per-topic docs lacked
Yefee8 Aug 1, 2026
09c6225
chore(deps): drop zustand, which nothing ever imported
Yefee8 Aug 1, 2026
18b4b4d
docs: follow the zustand removal through the docs and configs
Yefee8 Aug 1, 2026
8cb9c62
test(device): close the playlist chain, repeat-listen and release build
Yefee8 Aug 1, 2026
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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ call, it is wrong. This is the app's core promise and the reason it exists.

## Setup

Requirements: Node 22+, JDK 17 or 21, Android Studio with SDK Platform 35+, macOS or Linux.
Requirements: Node 22+, JDK 17 or 21, Android Studio with SDK Platform 36+, macOS or Linux.
(Expo SDK 57 compiles against 36; this said 35 until `app.sh` checked it and was wrong.)

### Environment (macOS)

Expand Down Expand Up @@ -108,8 +109,10 @@ These are not preferences. Violating them is a bug.
typed query functions.
4. **No business logic in component bodies.** Logic goes in `src/services/` (pure, testable) or
`src/features/*/hooks/` (stateful orchestration).
5. **Settings live in MMKV. Data lives in SQLite.** Don't mix them. Zustand holds transient
player/UI state only, never anything that must survive a restart.
5. **Settings live in MMKV. Data lives in SQLite.** Don't mix them. Transient player/UI state
lives in the `AudioEngine` singleton and the other module-level stores, read from React
through `useSyncExternalStore` — never anything that must survive a restart. There is no
global state library; don't add one.
6. **Layers point downward.** `components → hooks → services → db`. Never the reverse. A service
must not import a component.

Expand Down
88 changes: 88 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Contributing

Read [AGENTS.md](AGENTS.md) first. It is the house style and it is binding —
this file is the process around it, not a summary of it.

## The gate

Nothing is done until all four pass:

```bash
npm run lint
npm run typecheck
npm test
cd android && ./gradlew :audio-tags:testDebugUnitTest
```

Do not weaken a test to make it pass, and do not skip one. If a test is wrong,
fix the test and say in the commit message why it was wrong — that is useful
information, not an admission.

## Commits

Conventional commits: `feat:`, `fix:`, `refactor:`, `perf:`, `docs:`, `test:`,
`chore:`. Scope where it helps: `feat(shuffle): add discovery algorithm`.

**One logical change per commit.** Never one commit per phase of work.

The message body is the part that matters. It should say what changed and *why*,
and it is worth more than the diff — the diff already says what changed. In
particular:

- If you measured something, put the numbers in. Before and after.
- If you were wrong about the cause before you found the real one, say so. The
next person will have the same wrong idea.
- If you decided not to do something, say what and why.

Commit messages in this repository are read as the project's record. Several of
them are the only place a subtle decision is written down.

## Claims

- **A performance claim needs a measurement.** Before and after, with the device
and the conditions. `src/services/perf` exists for this; see
[docs/performance.md](docs/performance.md) for the method.
- **A UI change needs a device.** A screenshot or a specific observation —
"the toast clears the tab bar and has a dismiss control", not "should work".
The emulator is fine for behaviour; frame timing needs real hardware.
- **Do not claim something works without running it.**

## Decisions

Anything non-obvious gets a short ADR in `docs/adr/NNN-title.md`: context,
decision, consequences. Three paragraphs is plenty. Include the option you
rejected and why — an ADR that only argues for what was chosen is half a record.

If a requirement turns out to be a bad idea once you are in the code, say so and
propose the alternative. Do not silently build something different, and do not
build something you know is wrong because it was asked for.

## Documentation

Docs are part of the work, not cleanup afterwards. A phase is not done until the
relevant `docs/*.md` is updated in the same change.

Every exported component and service function gets a one-line JSDoc saying what
it does. Skip comments that restate the code; write the ones that explain why
the code is not the obvious thing.

## Things that will fail review

- A network call, analytics, crash reporting, or any SDK that phones home.
- A colour, spacing value, or user-facing string hardcoded in a component.
- A spacing class outside the scale. `src/theme/scale.test.ts` catches these,
and it exists because five of them shipped invisible.
- A string added to `en.json` but not `tr.json`, or the reverse.
`src/i18n/locales.test.ts` catches this.
- A component over 300 lines.
- `FlatList` where a list can grow.
- A hand-edit to `android/` or `ios/`, which are generated and git-ignored.
- Deleting a track row during rescan because a file is temporarily missing —
mark `is_missing = 1` instead, so playlists and history survive an unmounted
SD card.

## Pull requests

Say what changed, why, and how it was verified on a device. If something is
unverified, say that too — an honest gap is worth more than a confident guess,
and it tells the reviewer where to look.
233 changes: 233 additions & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
# Mufify — continue and finish

Offline-only Android music player. React Native + Expo SDK 57, TypeScript strict.
Repo: `/Users/yefee/Desktop/projeler/mufify`, branch **`fix/performance-ux-stats`**.

## Read first

`AGENTS.md` (binding house style), then `README.md`, `CONTRIBUTING.md`,
`docs/performance.md` (every measurement taken so far), `docs/components.md`,
and all of `docs/adr/`.

## Before anything else

```bash
git status && git log --oneline -15
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.
Do not build on red.

## Do NOT branch from `main`

`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.

## State

Everything in the last brief is done: performance (1a/1b/1c), all seven UI/UX
items, all four statistics items, the regression pass, and the release docs.
**68+ commits, unpushed.** No PR has been opened — nobody has asked for one.

Read the commit messages rather than re-deriving. Several are the only place a
decision is written down.

## Devices

**Mi 9T `7a6f8791`** — the user's real phone, API 29, MIUI. Connected and
running the app right now (dark theme, Turkish, 521 tracks).

- `adb install`, `am start`, `logcat` all work.
- **`adb shell input` and `pm grant` are blocked** (`SecurityException:
Injecting to another application requires INJECT_EVENTS`). Every tap needs a
human. **Batch them and ask once.**
- **MIUI's logcat rate limiter ("chatty") silently discards lines** under load.
If a measurement is missing, that is usually why — not a bug. Two per-render
counters were removed for exactly this reason; do not add more.
- Cold start is ~40 s here because it is a debug build pulling JS from Metro.
`adb -s 7a6f8791 reverse tcp:8081 tcp:8081` after every reconnect.

**Pixel_7 AVD** — full automation, `input` works. Frame timing is worthless
(SwiftShader). Degrades badly after several hours up; restart it rather than
fighting flaky taps.

## What is actually left

### 1. Frame timing on the Mi 9T — still never obtained

**The only item on this list that no amount of automation can close.** The AVD
renders through SwiftShader, so its frame numbers are worthless; MIUI blocks
`adb shell input`, so the phone cannot be scrolled without a human hand. It
needs both halves at once and there is no substitute.

The user uninstalled the app from the phone on 2026-08-01, so this now needs a
reinstall first. Then, with the app on the Library tab:

```bash
adb -s 7a6f8791 shell dumpsys gfxinfo dev.mufify.app reset
# human scrolls hard for ~10 seconds
adb -s 7a6f8791 shell dumpsys gfxinfo dev.mufify.app framestats
```

Note the package is `dev.mufify.app`, not `com.mufify` — querying the wrong one
returns "No process found", which reads exactly like the app having died.

This is the only valid source for a 60 fps claim, and `docs/performance.md`
deliberately makes no frame-rate claim until it exists. Record it there.

### 2. ~~Playlist chain, end to end~~ — DONE 2026-08-01

Verified on the Pixel_7 AVD in one pass, `stats_rollups` playlist rows included.
Full record in `docs/performance.md` under "Device verification".

Two things worth carrying forward:

- Every control has an `accessibilityLabel`, so drive the UI from
`uiautomator dump` by label rather than guessing coordinates. That is what
made this reproducible where the previous attempt was not.
- The reorder handle is `Gesture.Pan().activateAfterLongPress(120)`. A plain
`adb shell input swipe` **never activates it**; you need
`input motionevent DOWN` → hold → `MOVE`s → `UP`.

Reading the database — **copy the `-wal` too.** WAL means recent writes are not
in the main file, and copying only `mufify.db` shows an empty `play_events`,
which looks exactly like the bug you would then go hunting:

```bash
adb -s <dev> shell "run-as dev.mufify.app cat files/SQLite/mufify.db" > /tmp/m.db
adb -s <dev> shell "run-as dev.mufify.app cat files/SQLite/mufify.db-wal" > /tmp/m.db-wal
sqlite3 /tmp/m.db "SELECT entity_type, COUNT(*) FROM stats_rollups GROUP BY 1;"
```

### 3. ~~Repeat-listen seek-back~~ — DONE 2026-08-01

Two distinct `outcome=play` rows for one track from two seek-backs, rollups
agreeing. Recorded in `docs/performance.md`.

### 4. ~~Release build~~ — DONE 2026-08-01

Installed and run for the first time, on the AVD rather than the phone, with
`adb reverse --remove-all` so it could not fall back to Metro. Boots clean, all
four tabs render, no `INTERNET` permission in the manifest. Details in
`docs/performance.md`.

Still **not measured** — functional smoke test only. Every number in this repo
is from a debug build, which overstates JS cost.

### 4b. A UX gap found while doing the above

`MiniPlayer` is rendered only in `app/(tabs)/_layout.tsx`. Pushed stack screens
therefore have no transport control: start playback from a playlist detail
screen and there is no visible player, and no way to reach Now Playing without
going back to a tab. It follows from the routing structure rather than being a
defect, so it is a design call rather than a bug fix — but it is a real gap and
nobody has decided about it.

### 5. One finding from the phone's database (the other is closed)

Read on 2026-08-01 from the Mi 9T (`stats_rollups`, `tracks`). The codec finding
below was chased and closed the same day — it was never a defect. The remaining
one needs a human to press Scan.

**Stage two is 446 of 521 short.** `last_scanned_at IS NULL` for 446 rows and
set for 75. That is consistent with a scan that was cancelled or a process that
was killed, and the design says it resumes from exactly there — the null column
*is* the queue. Worth confirming that pressing Scan resumes rather than
restarts, because it is a designed behaviour nobody has watched happen.

**~~`codec` is null for all 521 rows~~ — RESOLVED, not a defect. Do not
reinvestigate.** It is `codecOf` in `src/services/scanner/trackMapping.ts`
working as designed: it returns null whenever the MIME subtype is already a
container name, because `audio/mpeg` otherwise renders the strip `MP3 · mpeg`
— the same fact spelled worse. `flac`, `mp4`, `mpeg` and `wav` are all in
`CONTAINER_NAMES`, so **a library of mainstream formats has a null codec on
every row, always.**

The premise behind the alarm was wrong twice over: `codec` never comes from the
native reader, so "the reader returned one field and not the other" described a
path that does not exist; and the emulator's `FLAC · 44.1 kHz · 16-bit` is the
*container* column, which was being compared against the phone's *codec*
column. Pulled from the device to confirm — the 75 enriched rows read
`container=FLAC|M4A, codec=NULL, bitrate=143, sample_rate=44100, bit_depth=16,
channels=2`. The phone renders exactly what the emulator does. There is no API
29 versus 35 difference and nothing to fix. (`bit_depth` is null on the one M4A
row, also correct — AAC is lossy and has no bit depth.)

`trackMapping.test.ts` now pins this with the device case named, so the next
reading of a null codec column resolves in one test file instead of a device
session.

Note the library on that phone is **synthetic test files** (`perf-NNN`, album
`bulk`, no artist, no artwork), not the user's music. Scanning their real
library is the only way to tell these apart from a tagging artefact — and it is
also the only way `artists` and artwork get exercised at all, since 0 of 521
current rows have either.

### 6. Phase 10 leftovers

Play Store listing copy (EN + TR), Data Safety answers, release AAB build
instructions, screenshots for the README.

`docs/architecture.md` is **written** — startup ordering, the three places state
lives, the scan and listen flows, and the native boundary. Screenshots are the
one Phase 10 item that needs a device.

## Traps already paid for — do not rediscover these

- **`tailwind.config.js` overrides the spacing scale.** A class built from a
value outside it compiles to *nothing* — no warning, no size. Five shipped
invisible, including the swipe-to-queue reveal strip, whose icon had never
been seen. `src/theme/scale.test.ts` now fails on any such class. The same is
true of colours: there is no `danger` token, and `text-danger` silently does
nothing.
- **Axis-locking a pan gesture on the first `onUpdate` is wrong.** Both
translations are still `0`, and `Math.abs(0) >= Math.abs(0)` is true, so every
gesture classifies as horizontal and vertical ones are silently discarded.
Wait for ~6 px of real movement.
- **`router.push` on a control that is also reachable by gesture stacks two
screens.** The drag fires the handler while the underlying `Pressable` still
registers a press. Use `router.navigate`. Symptom: dismiss *and* the close
button both appear broken, because each correctly pops one of two.
- **The React Compiler's `react-hooks/immutability` rule rejects mutating a
Reanimated shared value captured by a hook.** Build gestures inline (as
`Scrubber` does) unless the component has many instances; `SwipeableRow`
memoizes because it has ~40 in a list and needs an eslint-disable for it.
- **NativeWind `className` only works on components it knows.** Register in
`src/theme/interop.ts` with `cssInterop`.
- **A virtualized list needs a bounded flex parent** — wrap in `<View className="flex-1">`.
- **FlashList's default `drawDistance` is 250 px.** With 64 px rows that is under
four rows of buffer; a fling outruns it and leaves blank rows. It is 1200 now.
- **expo-router + React Compiler:** pass `(props) => <C {...props} />` to
`tabBar`, never the component.
- **`dumpsys media_session` lies about this app.** Use the engine's own stream.
- **Jest's `testMatch` treats any `*spec.ts` as a test.** Do not name a module that.
- **Property tests: seed with splitmix32, not an LCG.** Sequential LCG seeds give
correlated first draws.
- **MIUI marks call recordings `is_music=1`** — `MusicFilter` excludes recorder
folders by path.
- **`MediaMetadataRetriever` has no sample rate or bit depth below API 31.**
`AudioFormatReader` reads them from `MediaExtractor`.
- **New routes need Metro running** to regenerate `.expo/types/router.d.ts`.
- **Release builds ship no `INTERNET` permission** — `plugins/withOfflineOnly.js`.
Never undo this.
- **`android/` is generated by CNG and git-ignored.** Native config goes in
`app.json` or a config plugin.
- **The stress-library seeder does not survive a restart** — the launch sweep
retires tracks MediaStore cannot see, and synthetic rows have no files. Seed
and measure in the same session.

## Working style

Small verified increments, one logical change per commit, and a commit message
that explains what and why — the user reads those as the report. Measure before
claiming a performance fix, and put before/after numbers in. Verify UI on a
device and say what you actually saw. When you were wrong about a cause before
finding the real one, write that down; the next person will have the same wrong
idea. Do not stop at decision points — pick the most defensible option, record
it in an ADR, keep going. Say plainly what is verified and what is still owed.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Yefee8

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading