Fix/final polish v1 - #4
Merged
Merged
Conversation
`.claude/worktrees/*` holds full checkouts of other branches, each with its
own `modules/audio-focus/package.json`. Four packages claiming the name
`audio-focus` makes the Haste map ambiguous, so `jest.mock('audio-focus')`
cannot resolve it at all — the new engine suite failed to run before its first
line — and every other run printed a duplicate-name warning nobody could act
on.
`testPathIgnorePatterns` already skipped the *tests* there. It does not touch
the module map, which is a separate index.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same miscount has been reported three times and twice recorded as "fixed
and verified against the database". Both verifications were real device
sessions and both were true when they were written; neither could be re-run,
so neither caught what came after.
The gap they left is specific. `ListenCycle` and `isRewindToRestart` each have
good unit tests and each is correct in isolation. Nothing covered the wiring
between them in `AudioEngine.onStatus` — which is where a listen is opened,
banked and reopened, and where every reported defect has actually lived.
So this replays a scripted status stream through the real engine, with
`expo-audio` behind a fake player and time on the fake timer clock that
`ListenCycle` already reads through `Date.now()`. The scenarios are the ones a
person performs, and their expected answers come from ADR 005 and ADR 011
rather than from whatever the code currently does:
a played start to finish -> one play
b repeat-one, three times round -> three plays, three start times
c past the play mark, dragged back -> two listens
rewind short of the 25% mark -> still one
rewind before it has earned a play -> still one
d abandoned early, skipped forward -> one listen, only the audio heard
e scrubbing back and forth -> no extra events
Plus the queue moving on, which is the other way a listen closes: a track
ending and a user skipping each bank exactly one, and a track that never
played banks nothing.
All thirteen pass against the engine as it stands, which is itself a finding —
the counting state machine is right for every tick stream a phone can produce,
so the next place to look for the miscount is the stream, not the machine.
The fake deliberately implements only what the engine calls. A method the
engine starts using shows up as a missing function rather than a silent no-op.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three reports, one layer. `PlayerLayer` mounts the transport strip and Now
Playing outside the router, absolutely positioned over every route, and the
order it drew them in was wrong in three separate ways.
**The mini player drew over an open Now Playing.** The overlay was `z-10`
under the strip's `z-20`, so the surface meant to cover everything was painted
beneath the thing it covers. The fade hid only part of it: `opacity` lived on
the row inside `MiniPlayer`, which left the strip's own panel background, its
top hairline and `MiniProgress` at full opacity — the panel and progress bar
across the bottom of the expanded player were the mini player, still there.
The fade moves up to the wrapper, so the whole strip goes together.
**The queue opened and was never seen.** `app/queue.tsx` was a modal route,
and an opaque full-screen overlay mounted above the navigator covers whatever
the navigator puts under it. Nothing was wrong with `QueueScreen`; it was
rendering correctly, one layer down, behind the player that opened it. That is
a property of the overlay rather than of that screen — any route pushed while
Now Playing is open would have vanished the same way — so the queue gets the
same treatment as Now Playing itself: a root-level sheet, one layer above it,
outside the overlay's transformed and clipped container. The route is gone
along with `router.navigate('/queue')`.
**The opening did not feel connected to the finger.** The gesture already
derived one shared value from `translationY` and the overlay was already
always mounted, so the structure was right. What was missing was everything
after the release: the spring started from rest however hard the strip was
flicked, so a throw and a slow drag opened at identical speed. Both ends of
the gesture now hand their velocity over, in expansion units per second.
The spring is softer and heavier with it (ζ ≈ 0.86 against 0.75, and no mass
term before), and opacity finishes at 40% of the travel instead of tracking
the whole thing — a sheet arriving, not two screens cross-fading. A tap also
mounts the player on press-in rather than on press, so the first frames of the
animation are not spent waiting for a render the animation itself triggered.
`playerLayerLayout` grows a second measurement, the strip's height, which
nothing consumes yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The header was clipped off the top and the transport row sat under the navigation bar. Two reports, one cause, and it is not the safe area — that was being applied. The column simply did not fit. The cover was sized from the screen width alone: a square of `width - 2 × gutter`, whatever that came to. On a 393 × 851 dp phone that is 345 dp of artwork above roughly 440 dp of title, spec strip, scrubber and transport, inside about 780 dp of usable height. `justify-center` then split the overflow between both ends, which is exactly the two symptoms. The cover was never asked whether it fit. So it is bounded on both axes now, and the height bound is measured rather than computed from a constant: the chrome below it is not a fixed height, because the title wraps to two lines and Turkish runs 10–20% longer than English. `ArtworkCarousel` takes the height flex leaves it and draws a square of `min(width - gutters, that height)`. `aspect-square w-full` is gone with it. A width-derived square is only square while the width is the tighter of the two bounds, and on this screen it was not — which is the same bug wearing the other reporter's words, "the artwork is the wrong size". Three gaps of 32 between four blocks was also 96 dp of air the column did not have; `gap-6` gives 24 back, and `pb-4` keeps the transport off the navigation bar rather than merely clear of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Playlists tab showed "0 tracks" beside Liked Songs while opening it showed the real ones — and the tab also drew "there is nothing here, make a playlist" underneath a visible row. Both come from the same habit, and neither is a second query computing the number differently. **The count was never told anything had changed.** `useLiveQuery` re-runs when a table changes, and the only table it watches is the one in `FROM` — it reads `query.config.table` and compares the name, so joined tables are invisible to it. `useFavoriteEntries` selected `from(tracks)` and joined `track_stats`, so it watched `tracks` and never `track_stats`. Liking a song writes only to `track_stats`. The tab therefore held whatever the query returned when it last mounted; the detail screen agreed with reality because it had just mounted. Selecting from `track_stats` is also the honest description of what the list is: favourites are `track_stats` rows. **The empty state was answering a different question from the list.** It asked `playlists.length`, which excludes Liked Songs, while the list rendered `rows`, which included it. So did the header count. Three views of one collection, computed three times — the thing `AGENTS.md` names outright: *a count and the list it describes come from one query*. `buildPlaylistRows` now builds the rows once, the header renders `rows.length`, and the empty state is a fact about the same array. Liked Songs is in it only when it holds something: a virtual playlist with nothing in it is not a destination yet, and an always-present "0 tracks" row is what forced the count and the list to disagree in the first place. `LIKED_SONGS_ID` moves to `services/playlists/order` and is re-exported. Importing it from the query module drags SQLite into every test that needs to name this playlist — the module opens the database at import time, which the new test hit immediately. The regression is pinned: whatever the rows are built from, the empty state can only be true when there are none of them. Also carries this screen's share of the mini-player bottom inset, which the next commit applies everywhere else — the two changes are three lines apart in the same list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The transport strip is always visible once something is playing, and it is absolutely positioned over the routes rather than laid out with them. So the last rows of every list sat behind it, permanently unreachable — the bottom of the library, the bottom of a playlist, the last setting. One measured number, published by the layer that owns the strip and read through `useMiniPlayerInset`, rather than a hand-tuned `pb-` on each screen: six screens with six guesses is six things to get wrong, and five of them were simply absent. It is a runtime measurement, so it cannot be a Tailwind class — the config overrides the spacing scale and compiles anything outside it to nothing at all. This is the exception `AGENTS.md` names. Where a content container needed both, the whole padding moved into the style object using `SPACING` tokens, rather than leaving which one wins to NativeWind's merge order between `contentContainerClassName` and `contentContainerStyle`. The tab bar is deliberately not in the number. On a tab route the screen already ends where the bar begins; on a pushed route there is no bar and the strip measures its own safe-area padding instead. Either way the strip's measured height is exactly the overlap. It is zero while nothing is playing, because the mini player renders nothing then — so a list gets its full height back rather than a band of dead space under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The third report of "the loop count is wrong", and the first one with a mechanism that explains why it was *sometimes* right. Every threshold in the counting rule is a fraction of the track's duration, and there are two durations that disagree. The scanner's comes from MediaStore and is null more often than anyone expects — a file copied onto the device and indexed before its metadata was read has a row with no duration at all, and it stays that way until stage two of a scan reaches it. The engine's comes from the open file and is authoritative. `classifyListen(msPlayed, 0)` returns `partial`; that is its first line. So a track with no stored duration recorded a `play_event` for every listen, moved neither counter, and vanished from the play counts. Ten listens produced ten honest-looking rows and a play count of zero. `PlaybackState.durationMs` already preferred the engine's figure — the comment saying MediaStore "is occasionally wrong" has been in `docs/player.md` all along. The listen handed to the recorder did not: it carried `track.durationMs` straight off the row. That gap is the defect. `FinishedListen` now carries the duration explicitly, and the engine fills it with its own, falling back to the scanner's only when the file never opened. This is why two rounds of device verification came back clean. Both used tracks whose stored duration was fine, and those counted correctly the whole time. The failure needs a track MediaStore has not finished reading, which is exactly the state a freshly copied file is in — and freshly copied files are what you reach for when testing playback. Three cases in `listenRecording.test.ts` cover it, and all three fail against the previous behaviour: a full listen to a track with no stored duration is a play, a repeat of one still splits in two, and a stored duration that is far too short no longer turns a genuine skip into a play. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The system notification showed "playing" for audio that had stopped, and drew no artwork at all for a track without a cover while the app drew its music-note placeholder. **The state.** `setActiveForLockScreen` was called on every track load. On a player that is already active it does not refresh a session — inside expo-audio it releases the MediaSession and builds a new one on the main queue. Between those two there is a window with no live session, and the notification's play/pause icon is drawn from `session.player.isPlaying` at the moment it is posted. A state change landing in that window is drawn against a released session and never corrected. Since our engine keeps one player for the app's lifetime and swaps sources through `replace()`, that window opened on every single track change. It is claimed once now and updated in place with `updateLockScreenMetadata`, which changes metadata on the live session and re-posts the notification with no release and no gap. `stop()` hands the session back and clears the flag. That also explains an entry in `docs/player.md` that had been filed as an unexplained quirk — `dumpsys media_session` reporting `state=NONE` with a stale title. It was catching the swap, and the swap was happening constantly. **The artwork.** A generated PNG of lucide's `music` mark, same stroke weight, on `--color-panel`, unpacked from the bundle to a `file://` path — the service loads artwork through `java.net.URL(...).openConnection()`, which knows nothing about `asset://` or a Metro URL. So a track with no cover looks the same in the notification as it does on screen. **No favourite button.** It cannot be done through this engine and ADR 015 records why in full: `AudioLockScreenOptions` has three fields and none is a custom action, the MediaSession is a private field of expo-audio's own service, and the routes that remain are a private fork of the library or reflection into its internals. Deferred to whenever the engine question in ADR 009 is reopened, and reported as not delivered rather than quietly dropped. ADR 014 records the other decision this branch made: the queue is a root-level sheet, because a route cannot be seen from under a root-mounted player overlay. `tsconfig` paths are reordered, most specific first. TypeScript picks the best pattern whatever the order; jest-expo turns them into a Jest moduleNameMapper in this order and Jest takes the first match, so `@/assets/x` was resolving to `src/assets/x` and no asset import resolved under test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LIKED_SONGS_ID` moved into `buildPlaylistRows`; the screen no longer names it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`HANDOFF.md` told the next session that `main` holds three files and must never be branched from. PR #2 merged the project into `main` on 2026-08-02, so that warning now points the wrong way — and following it blindly would be its own mistake, because the newest four commits still sat ahead of `main` on `fix/ux-round-2`. It says "check, in both directions" now, with the command. `docs/components.md` gains `QueueOverlay`, splits `PlayerLayer` from `NowPlayingOverlay`, and records the stacking order in the one place that decides it. Test count 292 -> 335. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the queue route took hardware back with it. Both Now Playing and the queue are mounted outside the navigator, so nothing else can handle it: back would pop the screen *underneath* an open player and leave the player sitting over a screen the user never chose. `PlayerLayer` is the only place that knows both states, so it takes the press and closes the innermost one — queue first, then the overlay. Registered only while one of them is open, so an ordinary screen keeps its own back behaviour. The handler is declared after `onExpandedChange` on purpose. A dependency array is built during render, so naming that `const` above its own declaration is a temporal dead zone error on every render rather than a lint nit — which is how this was found. Also `h-full` rather than `flex-1` on the centre artwork slot: it sits in a row, so `flex-1` put a flex basis of zero against the explicit width and left which one wins to the shrink factor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… did not `docs/stats.md` said the repeat-listen behaviour was "verified on device". It had been, twice, and both reports were true about what they measured. Both were wrong about what they implied. Every track those sessions used had a good stored duration, and those counted correctly the whole time — the failure needs a track whose MediaStore duration is missing, which is the state a freshly copied file is in and therefore exactly the file you reach for when testing playback. The matrix was written down before it was run this time. On the Pixel_7 AVD: a played start to finish 1 event, play pass b looped 7 passes, 7 events, 27-30s apart pass c past the play mark, rewound to 0 2 events pass d skipped forward could not be driven e scrubbed back and forth could not be driven (d) and (e) need a forward seek and there is no way to perform one from automation: the scrubber is a Reanimated pan that neither `input swipe` nor a hand-built `input motionevent` sequence activates, and both `cmd media_session dispatch fast-forward` and `KEYCODE_MEDIA_FAST_FORWARD` left the position where it was. Both are covered by the harness, which drives the real `AudioEngine.seekTo`. So: 5/5 in the harness, 3/5 on hardware, and the three that ran on hardware are the three the harness cannot model — real audio timing, a real status stream, a real write per event. The gaps in (b) are the load-bearing number. 27–30 seconds between consecutive `started_at_utc` values, each carrying ~27 s of `ms_played`. A double-written event would sit milliseconds from its twin. None did. The Mi 9T could not be driven at all: `adb shell input` is still refused, and its notification shade was stuck open with no shell command able to collapse it. Everything recorded is the emulator, and the file says so. `docs/performance.md` also gains the notification results, including one that closes an old open question: `dumpsys media_session` reporting `state=NONE` with a stale title was the session being released and rebuilt on every track change, which is the same window that let the notification show "playing" for stopped audio. Fixing one made the other truthful. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR is a broad “final polish” pass across the offline music player, focusing on correctness and UX consistency in three main areas: listen counting accuracy (including repeat-one and duration handling), player/queue overlay layering, and “Liked Songs” / library import flows.
Changes:
- Fix listen recording correctness end-to-end (repeat-one pass handling, authoritative duration for play/skip classification) and improve Android lock-screen/notification metadata + placeholder artwork.
- Re-architect Now Playing + Queue as root-level overlays (not routes) with a shared Reanimated expansion value; add measured mini-player inset so scrollable screens pad correctly.
- Add a virtual “Liked Songs” playlist backed by
track_stats(withfavorite_atmigration) and update library folder import UX + i18n strings.
Reviewed changes
Copilot reviewed 76 out of 80 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Reorders TS path mappings to avoid Jest moduleNameMapper collisions for @/assets/*. |
| src/services/stats/listenCycle.test.ts | Adds regression test for starting a new repeat-one pass after closing the previous one. |
| src/services/playlists/order.ts | Introduces LIKED_SONGS_ID constant in services layer to avoid DB import side effects in tests. |
| src/services/haptics/index.ts | Updates docstrings for haptic feedback helpers. |
| src/services/audio/types.ts | Extends FinishedListen to carry authoritative durationMs for play/skip classification. |
| src/services/audio/testing/playbackHarness.ts | Adds an AudioEngine-driven harness to script playback status ticks for end-to-end listen counting tests. |
| src/services/audio/testing/fakeAudioPlayer.ts | Adds a minimal fake expo-audio player to support the playback harness. |
| src/services/audio/queue.ts | Extracts “restart current track” threshold logic into shouldRestartCurrentTrack + constant. |
| src/services/audio/queue.test.ts | Adds tests for shouldRestartCurrentTrack ten-second threshold behavior. |
| src/services/audio/notificationArtwork.ts | Adds cached async preparation of a file-backed placeholder artwork URI for lock screen/notification. |
| src/services/audio/listenRecording.test.ts | Adds end-to-end listen counting matrix tests against the real AudioEngine wiring. |
| src/services/audio/AudioEngine.ts | Fixes listen reporting duration source, repeat-one reopen behavior, previous threshold, and lock screen binding/metadata update behavior. |
| src/i18n/locales/tr.json | Adds strings for folder import + liked songs; removes selection-related strings. |
| src/i18n/locales/en.json | Adds strings for folder import + liked songs; removes selection-related strings. |
| src/features/stats/StatsScreen.tsx | Applies measured mini-player bottom inset and switches ScrollView content container spacing to token-based styles. |
| src/features/settings/SettingsScreen.tsx | Applies measured mini-player bottom inset and switches ScrollView content container spacing to token-based styles. |
| src/features/playlists/PlaylistsScreen.tsx | Builds playlist rows via a shared function (including Liked Songs), fixes header count vs rendered rows, and adds mini-player bottom inset. |
| src/features/playlists/playlistRows.ts | Adds pure helpers to build the playlist rows list + empty-state condition from one source of truth. |
| src/features/playlists/playlistRows.test.ts | Adds unit tests covering Liked Songs row inclusion/ordering and empty-state consistency. |
| src/features/playlists/PlaylistDetailScreen.tsx | Adds Liked Songs virtual playlist handling and disables rename/delete/reorder/remove for liked list; adds mini-player inset. |
| src/features/playlists/components/PlaylistEntryRow.tsx | Makes onRemove optional and conditionally renders the remove button for Liked Songs. |
| src/features/playlists/components/PlaylistDetailHeader.tsx | Makes rename/delete actions optional and removes “add tracks” affordance. |
| src/features/playlists/components/AddTracksSheet.tsx | Removes the playlist-side “add tracks” picker sheet component. |
| src/features/playlists/components/AddToPlaylistSheet.tsx | Refactors minor layout/props docs; formatting cleanup. |
| src/features/player/QueueScreen.tsx | Converts queue to a non-route surface with onClose, and fixes safe-area insets for full-screen sheet. |
| src/features/player/PlayerScreen.tsx | Converts Now Playing to a root overlay surface (no router), takes callbacks for expand + queue open. |
| src/features/player/playerLayerLayout.ts | Adds a module-level store for tab bar height and mini-player height; exposes useMiniPlayerInset(). |
| src/features/player/PlayerLayer.tsx | Adds root stacking layer: mini-player strip + Now Playing overlay + Queue overlay, with back handling and measured strip height publication. |
| src/features/player/playerExpansion.ts | Adds root-owned Reanimated mutable shared value for mini-player ↔ Now Playing expansion progress. |
| src/features/player/listenRecorder.ts | Records listens using FinishedListen.durationMs (engine-authoritative), not track.durationMs. |
| src/features/player/components/QueueOverlay.tsx | Adds root-level queue sheet overlay with reduced-motion-aware enter/exit animations. |
| src/features/player/components/NowPlayingOverlay.tsx | Adds root-level Now Playing overlay driven by shared expansion value (opacity + translateY). |
| src/features/player/components/MiniPlayer.tsx | Reworks gestures to drive shared expansion value, adds velocity handoff, and removes router navigation. |
| src/features/player/components/ArtworkCarousel.tsx | Reworks vertical drag to drive shared expansion, and makes artwork sizing bounded by both width and available height. |
| src/features/player/artworkSize.ts | Adds helper to compute bounded artwork square size from width + measured available height. |
| src/features/player/artworkSize.test.ts | Adds unit tests for bounded artwork sizing behavior. |
| src/features/library/LibraryTracks.tsx | Removes multi-select/selection handling and simplifies row press/long-press behavior. |
| src/features/library/LibraryScreen.tsx | Adds confirm-then-import folder flow, shows full-screen import progress modal, and updates scan banner conditions. |
| src/features/library/hooks/useTrackActions.ts | Updates docs to reflect removal of selection bar usage. |
| src/features/library/hooks/useSelection.ts | Removes selection hook entirely. |
| src/features/library/hooks/useScan.ts | Splits folder import into pickFolder + confirmed importFolder with isFolderImporting flag. |
| src/features/library/components/TrackRow.tsx | Removes selection checkbox UI and accessibility role/state logic. |
| src/features/library/components/TrackList.tsx | Updates swipe label key, removes selection props, and adds bottom inset padding via useMiniPlayerInset(). |
| src/features/library/components/TrackActionSheet.tsx | Removes selection action and updates add-to-queue label key. |
| src/features/library/components/SelectionBar.tsx | Removes selection action bar component. |
| src/features/library/components/LibraryRow.tsx | Removes selection gating logic; keeps memoization focused on primitives. |
| src/features/library/components/LibraryHeader.tsx | Removes selection entry point button and updates docs accordingly. |
| src/features/library/components/FolderImportModal.tsx | Adds a blocking full-screen progress modal for confirmed folder imports. |
| src/features/library/components/CollectionGrid.tsx | Adds bottom inset padding and switches container padding to token-based styles. |
| src/db/schema.ts | Adds nullable track_stats.favorite_at column for sorting favorites by recency. |
| src/db/queries/tracks.ts | Updates setFavorite to store/clear favoriteAt timestamp; minor formatting cleanup. |
| src/db/queries/scanning.ts | Documentation and formatting cleanup in scan query pipeline. |
| src/db/queries/playlists.ts | Re-exports LIKED_SONGS_ID, factors entry selection, and adds useFavoriteEntries() query for virtual liked playlist. |
| src/db/migrations/migrations.js | Adds migration 0003 to migrations list. |
| src/db/migrations/meta/0003_snapshot.json | Adds Drizzle snapshot for migration 0003. |
| src/db/migrations/meta/_journal.json | Records migration 0003 in Drizzle journal. |
| src/db/migrations/0003_numerous_cannonball.sql | Adds SQL migration to add favorite_at column. |
| src/db/migrations.test.ts | Adds test asserting favorite_at is nullable integer (doesn’t invalidate existing rows). |
| package.json | Adds expo-asset dependency to support placeholder asset unpacking. |
| jest.config.js | Ignores .claude worktrees at module resolution level to avoid Haste map duplicate package name conflicts. |
| HANDOFF.md | Updates branching guidance and test suite counts; adds watchman note for Jest. |
| docs/stats.md | Updates stats phase status, documents duration source bug + harness coverage, and corrects device verification claims. |
| docs/scanner.md | Updates wording for user-initiated library scan + folder import flow. |
| docs/player.md | Documents “claim session once, then update metadata” behavior and notification placeholder artwork approach. |
| docs/performance.md | Adds device verification notes for this branch, including listen counting matrix and MediaSession checks. |
| docs/database.md | Documents Liked Songs as virtual + favorite_at semantics; updates seek/rewind counting rule wording. |
| docs/components.md | Updates component map to reflect removal of selection/add-tracks sheet and new player overlay architecture. |
| docs/adr/015-no-favourite-button-in-the-media-notification.md | Adds ADR documenting inability to add a favourite action via expo-audio’s lock screen API. |
| docs/adr/014-queue-is-a-root-sheet-not-a-route.md | Adds ADR documenting queue overlay decision and implications. |
| docs/adr/009-expo-audio-and-our-own-queue.md | Updates “previous” restart rule wording to ten-second rule. |
| docs/adr/008-permission-is-asked-not-assumed.md | Updates references from addFolder to pickFolder. |
| docs/adr/007-saf-folders-go-through-mediastore.md | Updates references from addFolder to importFolder and documents full-screen import progress. |
| docs/adr/006-manual-add-is-first-class.md | Notes partial supersession by ADR 010 and clarifies scans are user-initiated. |
| docs/01-TECH-STACK.md | Updates routing architecture description and file tree to reflect Now Playing as overlay, not route. |
| assets.d.ts | Adds TS module declarations for PNG/JPG imports (asset-registry numeric ids). |
| app/queue.tsx | Removes queue route (queue is now a root overlay). |
| app/player.tsx | Removes player route (Now Playing is now a root overlay). |
| app/(tabs)/_layout.tsx | Measures tab bar height for overlay layout and removes MiniPlayer from tab bar (now owned by PlayerLayer). |
| app/_layout.tsx | Wraps navigator with PlayerLayer and removes modal routes for player/queue. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+128
to
+141
| const query = db | ||
| .select({ | ||
| position: sql<number>`row_number() over (order by ${trackStats.favoriteAt} desc, ${tracks.id} desc) - 1`, | ||
| ...entrySelection, | ||
| }) | ||
| .from(trackStats) | ||
| .innerJoin(tracks, eq(tracks.id, trackStats.trackId)) | ||
| .leftJoin(artists, eq(artists.id, tracks.artistId)) | ||
| .leftJoin(albums, eq(albums.id, tracks.albumId)) | ||
| .where(and(eq(trackStats.isFavorite, 1), eq(tracks.isMissing, 0))) | ||
| .orderBy(desc(trackStats.favoriteAt), desc(tracks.id)); | ||
|
|
||
| const { data } = useLiveQuery(query); | ||
| return data; |
Comment on lines
+75
to
+81
| <ScrollView | ||
| contentContainerStyle={{ | ||
| gap: SPACING[8], | ||
| paddingHorizontal: SPACING[6], | ||
| paddingBottom: SPACING[16] + bottomInset, | ||
| }} | ||
| > |
Comment on lines
+113
to
+119
| <ScrollView | ||
| contentContainerStyle={{ | ||
| gap: SPACING[8], | ||
| paddingHorizontal: SPACING[6], | ||
| paddingBottom: SPACING[16] + bottomInset, | ||
| }} | ||
| > |
Comment on lines
+57
to
65
| /* | ||
| One style rather than a class plus a style. The bottom inset is a | ||
| runtime measurement — the transport strip's height — which no Tailwind | ||
| class can carry, and mixing `contentContainerClassName` with | ||
| `contentContainerStyle` leaves which padding wins to NativeWind's merge | ||
| order. The values are still design-system tokens. | ||
| */ | ||
| contentContainerStyle={{ paddingHorizontal: SPACING[4], paddingBottom: bottomInset }} | ||
| ListEmptyComponent={empty} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.