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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# The historical source revision contains one NUL separator; keep its repair reviewable.
src/db/queries/scanning.ts diff
5 changes: 5 additions & 0 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { View } from 'react-native';

import { Toaster } from '@/components/ui/Toaster';
import { MiniPlayer } from '@/features/player/components/MiniPlayer';
import * as perf from '@/services/perf';
import { useLifecycleTrace } from '@/services/perf/useLifecycleTrace';
import { useTheme } from '@/theme/useTheme';

Expand Down Expand Up @@ -57,6 +58,10 @@ export default function TabsLayout() {
return (
<Tabs
tabBar={renderTabBar}
screenListeners={({ route }) => ({
tabPress: () => perf.mark(`tab.${route.name}.focus`),
focus: () => perf.measure(`tab.${route.name}.focus`),
})}
screenOptions={{
headerShown: false,
// Indigo marks the active tab and nothing else in this bar.
Expand Down
9 changes: 8 additions & 1 deletion app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import { TabErrorBoundary } from '@/components/ui/TabErrorBoundary';
import { LibraryScreen } from '@/features/library/LibraryScreen';

export default LibraryScreen;
export default function LibraryRoute() {
return (
<TabErrorBoundary>
<LibraryScreen />
</TabErrorBoundary>
);
}
9 changes: 8 additions & 1 deletion app/(tabs)/playlists.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import { TabErrorBoundary } from '@/components/ui/TabErrorBoundary';
import { PlaylistsScreen } from '@/features/playlists/PlaylistsScreen';

export default PlaylistsScreen;
export default function PlaylistsRoute() {
return (
<TabErrorBoundary>
<PlaylistsScreen />
</TabErrorBoundary>
);
}
9 changes: 8 additions & 1 deletion app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import { TabErrorBoundary } from '@/components/ui/TabErrorBoundary';
import { SettingsScreen } from '@/features/settings/SettingsScreen';

export default SettingsScreen;
export default function SettingsRoute() {
return (
<TabErrorBoundary>
<SettingsScreen />
</TabErrorBoundary>
);
}
9 changes: 8 additions & 1 deletion app/(tabs)/stats.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import { TabErrorBoundary } from '@/components/ui/TabErrorBoundary';
import { StatsScreen } from '@/features/stats/StatsScreen';

export default StatsScreen;
export default function StatsRoute() {
return (
<TabErrorBoundary>
<StatsScreen />
</TabErrorBoundary>
);
}
14 changes: 7 additions & 7 deletions docs/adr/012-artist-and-album-shelves.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

The library had one face: an alphabetical list of every track. The tech stack
doc calls for "Tracks / Albums / Artists / Genres" segments, and until now only
the first existed. Nothing in the schema was missing — `artists` and `albums`
have been populated by the scanner since Phase 2 — so this was a query and a
screen, not a data problem.
the first existed. The schema already had `artists` and `albums`; scanner writes
now resolve their foreign keys from MediaStore and stage-two tags, so the shelf
remains a query and screen concern rather than duplicated metadata.

## Decision

Expand Down Expand Up @@ -59,10 +59,10 @@ bounded by the result limit; the card queries use `min(artwork_path)` over the
group, which is arbitrary but stable — and stable matters more than which,
because a card whose cover changes between renders looks broken.

Both card queries `INNER JOIN` tracks rather than starting from `artists`. The
artist table gains a row the first time a name is seen and nothing ever removes
one, so a left join would list artists with zero tracks. What is on the device is
what the library shows.
Both card queries start at present `tracks`, so an artist row left behind by a
removed album cannot show as an empty shelf. They left-join their normalised
table and group null metadata into one translated unknown card; what is on the
device is what the library shows.

## Postscript: a class that compiled to nothing

Expand Down
23 changes: 23 additions & 0 deletions docs/adr/013-unknown-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 013 — Unknown metadata stays null

## Context

Some audio files have no artist or album tag. Treating that as a string made
one language leak into persistent data and left null-safe failures in grouping,
sorting and the artist screen. Skipping null artist and album rollups made the
statistics omit real listening time.

## Decision

Tracks keep `artist_id` and `album_id` null when metadata is absent. Library
queries group those nulls as one reserved display card, while rollups use entity
id `0`, which cannot collide with SQLite's positive ids. UI labels that entry
through i18n as Unknown Artist or Unknown Album; scanner maps `ARTIST`,
`ALBUM`, and `ALBUMARTIST` into the normalised foreign keys when they exist.

## Consequences

Missing metadata is visible, navigable and counted without storing a translated
placeholder row. Stats queries must left-join artists and albums for id `0`.
Balanced shuffle continues to treat each null artist as an individual bucket,
which avoids inventing a single performer for unrelated loose tracks.
3 changes: 2 additions & 1 deletion docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ it is repeated here because that is the thing a reader needs before touching it.
| `Screen` | The standard frame: safe area, surface, display-face title. |
| `EmptyState` | Icon, one line, and the way out. Picks one of several phrasings per mount so the app does not read like a recording. |
| `ErrorState` | What failed in one plain sentence, and the retry. Never a raw error string. |
| `TabErrorBoundary` | Contains a render failure to its selected tab and shows `ErrorState`; retry remounts only that tab. |
| `Skeleton` | One placeholder block, sized by the caller. Pulses via a Reanimated worklet; stops dead under reduce-motion. |
| `SkeletonRows` | A list's worth, shaped like real rows so nothing jumps when data lands. Hidden from screen readers. |
| `SkeletonCards` | The same for the artist and album grids. Mirrors `CollectionGrid`'s two-column layout exactly. |
Expand Down Expand Up @@ -73,7 +74,7 @@ it is repeated here because that is the thing a reader needs before touching it.
|---|---|
| `PlayerScreen` | Now Playing. A modal route: somewhere you go from something and dismiss. |
| `ArtworkCarousel` | Three slots — previous, current, next — all mounted, so a neighbour slides in already decoded. Commits on distance **or** velocity. Rubber-bands at the ends of the queue. |
| `MiniPlayer` | The persistent transport strip. Subscribes to phase and track only, never position: doing otherwise reconciled it 20 times per ten seconds of playback. |
| `MiniPlayer` | The persistent transport strip. Subscribes to phase and track only, never position; horizontal swipe changes tracks and vertical swipe opens Now Playing. |
| `MiniProgress` | The progress hairline, and the only thing in the tab bar that hears about position. Width lives in a shared value; React renders it once. |
| `Scrubber` | The seek bar. The drag runs entirely in a worklet; React hears about it once, on release. |
| `SpecStrip` | The signature element — one monospaced line of a file's technical truth. |
Expand Down
18 changes: 17 additions & 1 deletion docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,22 @@ Twenty is exactly the 2 Hz status interval. Fixed by two narrow subscriptions
nothing changed, plus moving position into `MiniProgress` — one animated view
whose width lives in a Reanimated shared value and which React renders once.

### Row press

The library row previously mapped every track to a playable queue and searched
that same array inside its press handler. `MUFIFY_PERF` measured one press on
the same 10,000-track synthetic library and the same first row on the Pixel_7
AVD debug build:

| | Before | After |
|---|---:|---:|
| Press handler | **16.6 ms** | **0.8 ms** |
| Press to mini-player state | **168.5 ms** | **37.4 ms** |

The queue and id-to-index map now update when the query result changes, not
when a row is pressed. The second number ends when the mini-player observes the
new current track; it is UI feedback latency, not time to audible audio.

**Worth recording what this was not.** The first diagnosis was that the tab bar
next door was re-rendering with it. The counter said 0, both before and after:
React re-renders the component whose store changed and its children, not its
Expand Down Expand Up @@ -223,7 +239,7 @@ never been seen by anyone. `src/theme/scale.test.ts` now fails on any such class
## Regression pass

Run after the three critical sections closed, on the Pixel_7 AVD, with both
gates green — `lint`, `typecheck`, 292 JS tests across 20 suites, and
gates green — `lint`, `typecheck`, 302 JS tests across 21 suites, and
`:audio-tags:testDebugUnitTest` forced with `--rerun-tasks`.

| Area | Result |
Expand Down
5 changes: 4 additions & 1 deletion docs/scanner.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ usable within a second or two of a cold scan.
### Stage two — enrich

`enrichLibrary()`. Opens files in batches of 25 and fills in tags, the spec
strip and artwork.
strip and artwork. `ARTIST` resolves `tracks.artist_id`; `ALBUMARTIST` (or
`ARTIST` when absent) resolves the album's `artist_id`; and `ALBUM` resolves
`tracks.album_id`. This keeps the normalised tables and the track metadata in
step after a tag edit.

The queue is not a table: it is `tracks` where `last_scanned_at IS NULL`. Each
batch is written before the next starts, so a scan that is cancelled, crashes
Expand Down
11 changes: 7 additions & 4 deletions docs/stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,13 @@ week/month/year × track/artist/album/playlist incrementally, from
`applyRollups` inside `recordListen`.

One listen writes up to twelve cells — three periods times four entities,
minus whatever is null. `rollupDeltas` builds that as a product rather than by
hand, so adding a period or an entity type cannot be done to one and forgotten
in the others. Null artist and album ids are **skipped**, never defaulted to
zero: an untagged file must not accumulate against a phantom entity.
minus a missing playlist. `rollupDeltas` builds that as a product rather than
by hand, so adding a period or an entity type cannot be done to one and
forgotten in the others. A null artist or album uses the reserved rollup id
`0`: it cannot collide with SQLite's positive row ids, is left-joined at read
time, and is rendered as the active locale's “Unknown Artist” or “Unknown
Album”. The content tables still retain null — no translated phantom row is
stored in user data.

The artist and album come from the `tracks` row, not from the caller. The
player knows what it is playing, not how the library has it classified, and a
Expand Down
1 change: 1 addition & 0 deletions src/components/ui/SegmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function SegmentedControl<T extends string>({
<Pressable
key={option.value}
onPress={() => onChange(option.value)}
android_ripple={{ color: selected ? colors.onSignal : colors.etch }}
accessibilityRole="radio"
accessibilityState={{ selected }}
accessibilityLabel={option.label}
Expand Down
48 changes: 48 additions & 0 deletions src/components/ui/TabErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { View } from 'react-native';

import { i18n } from '@/i18n';

import { ErrorState } from './ErrorState';

export interface TabErrorBoundaryProps {
children: ReactNode;
}

interface TabErrorBoundaryState {
hasError: boolean;
}

/** Keeps a render failure inside one tab instead of taking down the whole app. */
// React render error boundaries require a class component.
export class TabErrorBoundary extends Component<TabErrorBoundaryProps, TabErrorBoundaryState> {
Comment on lines +16 to +18
override state: TabErrorBoundaryState = { hasError: false };

static getDerivedStateFromError(): TabErrorBoundaryState {
return { hasError: true };
}

override componentDidCatch(error: Error, info: ErrorInfo): void {
if (__DEV__) console.error('Tab render failed:', error, info.componentStack);
}

private retry = (): void => {
this.setState({ hasError: false });
};

override render(): ReactNode {
if (this.state.hasError) {
return (
<View className="flex-1 bg-surface">
<ErrorState
message={i18n.t('common.unexpectedError')}
retryLabel={i18n.t('common.tryAgain')}
onRetry={this.retry}
/>
</View>
);
}

return this.props.children;
}
}
Binary file modified src/db/queries/scanning.ts
Binary file not shown.
11 changes: 6 additions & 5 deletions src/db/queries/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { albums, artists, playlists, statsRollups, tracks } from '../schema';

export interface TopEntry {
id: number;
title: string;
/** Null only for the reserved unknown artist or album rollup row. */
title: string | null;
subtitle: string | null;
playCount: number;
msPlayed: number;
Expand Down Expand Up @@ -100,15 +101,15 @@ export function useTopTracks(periodType: PeriodType, periodKey: string, limit =
export function useTopArtists(periodType: PeriodType, periodKey: string, limit = 10) {
const query = db
.select({
id: artists.id,
id: statsRollups.entityId,
title: artists.name,
subtitle: sql<string | null>`null`,
playCount: statsRollups.playCount,
msPlayed: statsRollups.msPlayed,
artworkPath: artistCover,
})
.from(statsRollups)
.innerJoin(artists, eq(artists.id, statsRollups.entityId))
.leftJoin(artists, eq(artists.id, statsRollups.entityId))
.where(rankedRollups(periodType, periodKey, 'artist'))
.orderBy(desc(statsRollups.playCount), desc(statsRollups.msPlayed))
.limit(limit);
Expand All @@ -121,15 +122,15 @@ export function useTopArtists(periodType: PeriodType, periodKey: string, limit =
export function useTopAlbums(periodType: PeriodType, periodKey: string, limit = 10) {
const query = db
.select({
id: albums.id,
id: statsRollups.entityId,
title: albums.name,
subtitle: artists.name,
playCount: statsRollups.playCount,
msPlayed: statsRollups.msPlayed,
artworkPath: albumCover,
})
.from(statsRollups)
.innerJoin(albums, eq(albums.id, statsRollups.entityId))
.leftJoin(albums, eq(albums.id, statsRollups.entityId))
.leftJoin(artists, eq(artists.id, albums.artistId))
.where(rankedRollups(periodType, periodKey, 'album'))
.orderBy(desc(statsRollups.playCount), desc(statsRollups.msPlayed))
Expand Down
Loading